diff --git a/.cursor/rules/instructions.mdc b/.cursor/rules/instructions.mdc deleted file mode 100644 index 3dca909..0000000 --- a/.cursor/rules/instructions.mdc +++ /dev/null @@ -1,3 +0,0 @@ ---- -alwaysApply: true ---- diff --git a/.cursor/rules/project_description.mdc b/.cursor/rules/project_description.mdc deleted file mode 100644 index a178c80..0000000 --- a/.cursor/rules/project_description.mdc +++ /dev/null @@ -1,6 +0,0 @@ ---- -description: Documents Management Sytem Version 1.1.0 -globs: - - docs/LCBP3-DMS_V1_1_0_*.md -alwaysApply: true ---- diff --git a/Setting Project.md b/Setting Project T0.md similarity index 75% rename from Setting Project.md rename to Setting Project T0.md index af228f7..d03d2ba 100644 --- a/Setting Project.md +++ b/Setting Project T0.md @@ -375,7 +375,7 @@ JWT_EXPIRATION=8h คุณต้องการให้ผมช่วยตั้งค่า `ConfigModule` ใน NestJS เพื่ออ่านค่า `JWT_SECRET` นี้อย่างปลอดภัยพร้อม Validate ว่าห้ามเป็นค่าว่างไหมครับ? -## ได้เลยครับ เรามาลุยงาน **T0.1 Secure Configuration Setup** ตามแผน Backend Plan กันต่อครับ +## **T0.1 Secure Configuration Setup** ตามแผน Backend Plan กันต่อครับ เป้าหมายของ Task นี้คือการทำให้ระบบอ่านค่า Environment Variables ได้อย่างถูกต้อง และ **"ต้องไม่ Start"** ถ้าค่า Config สำคัญๆ (เช่น Database Password, JWT Secret) หายไป หรือไม่ถูกต้อง เพื่อความปลอดภัยครับ @@ -444,7 +444,7 @@ import { envValidationSchema } from './common/config/env.validation.js'; // ส validationSchema: envValidationSchema, // ใช้ Schema ที่เราสร้างเพื่อตรวจสอบ validationOptions: { // ถ้ามีค่าไหนไม่ผ่าน Validation ให้ Error และหยุดทำงานทันที - abortEarly: true, + abortEarly: true, }, }), @@ -471,7 +471,7 @@ import { envValidationSchema } from './common/config/env.validation.js'; // ส export class AppModule {} ``` -### 4\. สร้างไฟล์ Template สำหรับ Docker Override (ตามแผน T0.1) +### 4. สร้างไฟล์ Template สำหรับ Docker Override (ตามแผน T0.1) ตามแผนงาน เราต้องเตรียมไฟล์ `docker-compose.override.yml.example` เพื่อให้นักพัฒนาคนอื่นรู้ว่าต้อง Inject Secrets อย่างไร โดยไม่เปิดเผยความลับใน `docker-compose.yml` หลัก @@ -496,7 +496,7 @@ services: - MYSQL_PASSWORD=secret_password_here ``` -### 5\. ทดสอบความปลอดภัย (Validation Check) +### 5. ทดสอบความปลอดภัย (Validation Check) ลองทดสอบว่าระบบ Validation ทำงานถูกต้องหรือไม่ โดยการ **ลบ** หรือ **เปลี่ยนชื่อ** ตัวแปรสำคัญในไฟล์ `.env` (เช่น ลบ `JWT_SECRET`) แล้วลองรัน: @@ -508,10 +508,230 @@ pnpm start:dev App **ต้อง Crash** และแสดง Error Message ชัดเจน เช่น: `Config validation error: "JWT_SECRET" is required` -ถ้าขึ้นแบบนี้แสดงว่าระบบ **Secure Configuration** ของเราทำงานถูกต้องตามแผน T0.1 แล้วครับ\! 🎉 +ถ้าขึ้นแบบนี้แสดงว่าระบบ **Secure Configuration** ของเราทำงานถูกต้องตามแผน T0.1 แล้วครับ! 🎉 ----- -**ขั้นตอนถัดไป:** -เราจะไปต่อที่ **T0.2 Redis & Queue Infrastructure** เพื่อเตรียมระบบ Caching และ Background Job ไหมครับ? +## **T0.2 Redis & Queue Infrastructure** +เป้าหมายของ Task นี้คือเตรียม **Redis** สำหรับทำ 2 เรื่องสำคัญ: + +1. **Distributed Locking (Redlock):** ป้องกัน Race Condition เวลาออกเลขที่เอกสาร +2. **Message Queue (BullMQ):** สำหรับจัดการงานเบื้องหลัง (Background Jobs) เช่น การรวมอีเมลแจ้งเตือน (Digest Notification) + +มาเริ่มกันเลยครับ + +----- + +### 1. เพิ่ม Redis ใน Docker Compose + +แก้ไขไฟล์ `docker-compose.yml` เพื่อเพิ่ม Service Redis ครับ โดยเราจะตั้ง Password เพื่อความปลอดภัย + +**ไฟล์: `docker-compose.yml`** + +```yaml +version: '3.8' + +services: + # ... (mariadb & pma เดิม) ... + + # เพิ่ม Redis Service + redis: + image: redis:7-alpine + container_name: lcbp3-redis-local + restart: always + # ใช้ Command นี้เพื่อตั้ง Password + command: redis-server --requirepass "redis_password_secure" + ports: + - '6379:6379' + volumes: + - redis_data:/data + networks: + - lcbp3-net + +volumes: + db_data: + redis_data: # เพิ่ม Volume + +networks: + lcbp3-net: + driver: bridge +``` + +จากนั้นรันคำสั่งเพื่อ Start Redis: + +```bash +docker-compose up -d +``` + +### 2. อัปเดต Environment Config + +เพิ่มค่า Config ของ Redis ลงในไฟล์ `.env` และไฟล์ Validation ครับ + +**ไฟล์: `.env`** + +```env +# ... (ค่าเดิม) ... + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=redis_password_secure +``` + +**ไฟล์: `src/common/config/env.validation.ts`** + +```typescript +// ... (import Joi) ... + +export const envValidationSchema = Joi.object({ + // ... (App & Database Config เดิม) ... + + // 4. Redis Configuration (เพิ่มส่วนนี้) + REDIS_HOST: Joi.string().required(), + REDIS_PORT: Joi.number().default(6379), + REDIS_PASSWORD: Joi.string().required(), +}); +``` + +### 3. ติดตั้ง Library สำหรับ BullMQ + +เราจะใช้ `@nestjs/bullmq` ซึ่งเป็น Wrapper ของ `bullmq` (Redis Queue ที่ทันสมัยกว่า Bull ธรรมดา) + +รันคำสั่ง: + +```bash +pnpm add @nestjs/bullmq bullmq +``` + +### 4. ตั้งค่า BullModule ใน AppModule + +แก้ไข `src/app.module.ts` เพื่อเชื่อมต่อ Redis เข้ากับระบบ Queue ครับ + +**ไฟล์: `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'; + +@Module({ + imports: [ + // 1. Config (เดิม) + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + validationSchema: envValidationSchema, + validationOptions: { abortEarly: true }, + }), + + // 2. TypeORM (เดิม) + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + type: 'mariadb', + host: configService.get('DB_HOST'), + port: configService.get('DB_PORT'), + username: configService.get('DB_USERNAME'), + password: configService.get('DB_PASSWORD'), + database: configService.get('DB_DATABASE'), + autoLoadEntities: true, + synchronize: configService.get('NODE_ENV') === 'development', + }), + }), + + // 3. BullMQ (Redis) Setup [NEW] + BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + connection: { + host: configService.get('REDIS_HOST'), + port: configService.get('REDIS_PORT'), + password: configService.get('REDIS_PASSWORD'), + }, + }), + }), + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} +``` + +### 5. ทดสอบการเชื่อมต่อ Redis + +ลอง Start App ดูอีกครั้งครับ: + +```bash +pnpm start:dev +``` + +ถ้าไม่มี Error ขึ้น และ App Start ได้ปกติ แสดงว่า **T0.2 เสร็จสมบูรณ์** ครับ\! ตอนนี้เรามีทั้ง Database และ Redis พร้อมสำหรับการพัฒนา Business Logic แล้ว + +----- +### 6. **แก้ไข Error** +ปัญหานี้เกิดจากยังไม่ได้ **Import** `BullModule` เข้ามาในไฟล์ `src/app.module.ts` ครับ (TypeScript เลยไม่รู้จักว่า `BullModule` คืออะไร) + +ให้เพิ่มบรรทัดนี้ที่ส่วนบนสุดของไฟล์ `src/app.module.ts` ครับ: + +```typescript +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'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + validationSchema: envValidationSchema, + validationOptions: { abortEarly: true }, + }), + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + type: 'mariadb', + host: configService.get('DB_HOST'), + port: configService.get('DB_PORT'), + username: configService.get('DB_USERNAME'), + password: configService.get('DB_PASSWORD'), + database: configService.get('DB_DATABASE'), + autoLoadEntities: true, + synchronize: configService.get('NODE_ENV') === 'development', + }), + }), + // Queue Configuration + BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + connection: { + host: configService.get('REDIS_HOST'), + port: configService.get('REDIS_PORT'), + password: configService.get('REDIS_PASSWORD'), + }, + }), + }), + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} +``` diff --git a/Setting Project T1.md b/Setting Project T1.md new file mode 100644 index 0000000..bee7fd7 --- /dev/null +++ b/Setting Project T1.md @@ -0,0 +1,2195 @@ +# **BACKEND DEVELOPMENT** + +## **T1.1 CommonModule** + +ตอนนี้เราจะเข้าสู่ **Phase 1: Core Foundation** เริ่มจาก **Task T1.1 CommonModule - Base Infrastructure** ครับ + +เป้าหมายคือสร้าง "รากฐาน" ที่ทุก Module ต้องใช้ร่วมกัน เพื่อให้โค้ดเป็นระเบียบและลดความซ้ำซ้อนครับ + +### 1. สร้าง CommonModule + +เพื่อให้ NestJS รู้จักไฟล์ต่างๆ ในโฟลเดอร์ `common` เราควรสร้าง Module หลักมาห่อหุ้มไว้ครับ + +รันคำสั่ง: + +```bash +nest g module common +``` + +*(ถ้ามันฟ้องว่ามีไฟล์อยู่แล้ว ให้ข้ามขั้นตอนนี้ได้เลยครับ แต่ต้องแน่ใจว่า `app.module.ts` มี `CommonModule` ใน imports แล้ว)* + +----- + +### 2. สร้าง Base Entity (รากฐานของทุกตาราง) + +เราจะสร้าง Entity แม่แบบที่มีคอลัมน์มาตรฐาน (`id`, `created_at`, `updated_at`, `deleted_at`) เพื่อให้ Entity อื่นๆ (เช่น User, Project) สืบทอด (Extend) ไปใช้ได้เลย ไม่ต้องเขียนซ้ำครับ + +สร้างไฟล์: `src/common/entities/base.entity.ts` + +```typescript +import { + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm'; + +export abstract class BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @CreateDateColumn({ name: 'created_at' }) + created_at: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updated_at: Date; + + @DeleteDateColumn({ name: 'deleted_at', select: false }) // select: false เพื่อซ่อน field นี้โดย Default + deleted_at: Date; +} +``` + +----- + +### 3. สร้าง Standard Response Format (Interceptor) + +เพื่อให้ API ทุกเส้นตอบกลับในรูปแบบเดียวกันเสมอ (เช่น มี field `data`, `statusCode`, `message`) เราจะใช้ Interceptor ครับ + +สร้างไฟล์: `src/common/interceptors/transform.interceptor.ts` + +```typescript +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface Response { + statusCode: number; + message: string; + data: T; +} + +@Injectable() +export class TransformInterceptor + implements NestInterceptor> +{ + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + 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 + +สำหรับจัดการ Error ทั้งหมดในระบบ ให้ตอบกลับเป็น JSON ที่สวยงามและ **ไม่เปิดเผยข้อมูล Sensitive** (เช่น Stack Trace) ตามข้อกำหนด Security ครับ + +สร้างไฟล์: `src/common/exceptions/http-exception.filter.ts` + +```typescript +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.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)}`, + ); + + response.status(status).json({ + statusCode: status, + timestamp: new Date().toISOString(), + path: request.url, + message: status === 500 ? 'Internal server error' : message, // ซ่อน Detail กรณี 500 + }); + } +} +``` + +----- + +### 5. ลงทะเบียนใช้งาน Global ใน `main.ts` + +สุดท้าย เราต้องบอก NestJS ให้ใช้ Interceptor และ Filter ที่เราเพิ่งสร้างกับทุก Request ครับ + +แก้ไขไฟล์: `src/main.ts` + +```typescript +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 { HttpExceptionFilter } from './common/exceptions/http-exception.filter.js'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // 1. Global Prefix (เช่น /api/v1) + app.setGlobalPrefix('api'); + + // 2. Global Validation Pipe (ตรวจสอบ Input DTO) + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // ตัด field ส่วนเกินทิ้ง + transform: true, // แปลง Type อัตโนมัติ (เช่น string -> number) + 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()}`); +} +bootstrap(); +``` + +----- + +* **เสร็จสิ้น T1.1 ส่วน Infrastructure หลักแล้วครับ** + +ตอนนี้ระบบเรามี: + +1. **BaseEntity** ไว้ให้ตารางอื่นสืบทอด +2. **Standard Response** `{ statusCode, message, data }` +3. **Secure Error Handling** + +## **T1.2 AuthModule - JWT Authentication** + +Task นี้หัวใจสำคัญคือ "ความปลอดภัย" เราจะสร้างระบบ Login ที่ใช้ **JWT (JSON Web Token)** และเก็บรหัสผ่านแบบ **Bcrypt** ตามมาตรฐานครับ + +----- + +### 1. ติดตั้ง Libraries + +เราต้องใช้ Passport (สำหรับจัดการ Strategy), JWT, และ Bcrypt (สำหรับ Hash Password) + +รันคำสั่ง: + +```bash +pnpm add @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt +pnpm add -D @types/passport-jwt @types/bcrypt +``` + +### 2. สร้าง User Entity และ Module + +เนื่องจาก Auth ต้องทำงานกับ "ผู้ใช้" เราจำเป็นต้องสร้าง `UserModule` และ `User` Entity ขึ้นมาก่อนครับ (เป็นการทำ T1.3 บางส่วนล่วงหน้าเพื่อให้ T1.2 ทำงานได้) + +สร้างไฟล์: `src/modules/user/entities/user.entity.ts` +*(อ้างอิงโครงสร้างจาก Data Dictionary ตาราง `users`)* + +```typescript +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; +import { BaseEntity } from '../../../common/entities/base.entity.js'; // Import จาก Common + +@Entity('users') // ชื่อตารางใน DB +export class User extends BaseEntity { + // Map property 'id' ของ Code ให้ตรงกับ column 'user_id' ของ DB + @PrimaryGeneratedColumn({ name: 'user_id' }) + id: number; + + @Column({ unique: true, length: 50 }) + username: string; + + @Column({ name: 'password_hash' }) // ชื่อ column ใน DB + password: string; // ชื่อ property ใน Code + + @Column({ unique: true, length: 100 }) + email: string; + + @Column({ name: 'first_name', nullable: true, length: 50 }) + firstName: string; + + @Column({ name: 'last_name', nullable: true, length: 50 }) + lastName: string; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + // เดี๋ยวเราจะมาเพิ่ม Relation กับ Role/Org ทีหลังใน T1.3 +} +``` + +จากนั้นสร้าง `UserModule` และ `UserService` แบบย่อเพื่อใช้ค้นหา User: + +รันคำสั่ง: + +```bash +nest g module modules/user +nest g service modules/user +``` + +แก้ไข `src/modules/user/user.module.ts`: + +```typescript +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserService } from './user.service.js'; +import { User } from './entities/user.entity.js'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], // จดทะเบียน Entity + providers: [UserService], + exports: [UserService], // Export ให้ AuthModule เรียกใช้ได้ +}) +export class UserModule {} +``` + +แก้ไข `src/modules/user/user.service.ts`: + +```typescript +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from './entities/user.entity.js'; + +@Injectable() +export class UserService { + constructor( + @InjectRepository(User) + private usersRepository: Repository, + ) {} + + async findOneByUsername(username: string): Promise { + return this.usersRepository.findOne({ where: { username } }); + } + + // ใช้สำหรับ Seed หรือ Register เบื้องต้น + async create(userData: Partial): Promise { + const user = this.usersRepository.create(userData); + return this.usersRepository.save(user); + } +} +``` + +### 3. สร้าง Auth Module และ Service + +รันคำสั่ง: + +```bash +nest g module common/auth +nest g service common/auth +nest g controller common/auth +``` + +แก้ไข `src/common/auth/auth.module.ts` เพื่อตั้งค่า JWT: + +```typescript +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service.js'; +import { AuthController } from './auth.controller.js'; +import { UserModule } from '../../modules/user/user.module.js'; +import { JwtStrategy } from './jwt.strategy.js'; // เดี๋ยวสร้างไฟล์นี้ + +@Module({ + imports: [ + UserModule, // Import เพื่อเรียกใช้ UserService + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_EXPIRATION') || '8h' + }, + }), + }), + ], + providers: [AuthService, JwtStrategy], + controllers: [AuthController], + exports: [AuthService], +}) +export class AuthModule {} +``` + +### 4. Implement Auth Logic (Hash & Validate) + +แก้ไข `src/common/auth/auth.service.ts`: + +```typescript +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { UserService } from '../../modules/user/user.service.js'; + +@Injectable() +export class AuthService { + constructor( + private userService: UserService, + private jwtService: JwtService, + ) {} + + // ตรวจสอบ Username/Password + async validateUser(username: string, pass: string): Promise { + const user = await this.userService.findOneByUsername(username); + if (user && (await bcrypt.compare(pass, user.password))) { + // ตัด password ออกก่อนส่งกลับ + const { password, ...result } = user; + return result; + } + return null; + } + + // สร้าง Token (Login) + async login(user: any) { + const payload = { username: user.username, sub: user.id }; + return { + access_token: this.jwtService.sign(payload), + }; + } + + // (Optional) ฟังก์ชัน Register สำหรับสร้าง User แรก (Admin) + async register(userDto: any) { + const salt = await bcrypt.genSalt(); + const hashedPassword = await bcrypt.hash(userDto.password, salt); + return this.userService.create({ + ...userDto, + password: hashedPassword, + }); + } +} +``` + +### 5. สร้าง JWT Strategy (ตัวตรวจสอบ Token) + +สร้างไฟล์ `src/common/auth/jwt.strategy.ts`: + +```typescript +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET'), + }); + } + + async validate(payload: any) { + // สิ่งที่ Return ตรงนี้จะไปอยู่ใน request.user + return { userId: payload.sub, username: payload.username }; + } +} +``` + +### 6. สร้าง Auth Controller (Login Endpoint) + +แก้ไข `src/common/auth/auth.controller.ts`: + +```typescript +import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from './auth.service.js'; + +@Controller('auth') +export class AuthController { + constructor(private authService: AuthService) {} + + @Post('login') + async login(@Body() req) { + // รับ username/password จาก Body + const user = await this.authService.validateUser(req.username, req.password); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + return this.authService.login(user); + } + + // Endpoint ชั่วคราวสำหรับสร้าง User คนแรก (Admin) + @Post('register-admin') + async register(@Body() req) { + return this.authService.register(req); + } +} +``` + +----- + +### ✅ สรุปสิ่งที่ทำไป + +1. สร้าง `User` Entity (Map กับ DB `users`) +2. สร้าง `AuthModule` ที่ Config JWT ด้วย Secret จาก `.env` +3. ทำระบบ Hash Password ด้วย `bcrypt` +4. เปิด Endpoint `/auth/login` และ `/auth/register-admin` + +### ทดสอบ (Manual Test) + +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` กลับมาครับ + +ลองทำตามขั้นตอนนี้ดูครับ ติดขัดตรงไหนแจ้งได้เลยครับ (โดยเฉพาะเรื่อง `.js` extension ใน import ถ้าใช้ ESM อาจจะต้องระวังนิดนึงครับ) + +### **7. error** + +ปัญหานี้เกิดจากการที่เราเปิด **Strict Mode** (`noImplicitAny: true`) ใน `tsconfig.json` ทำให้ TypeScript บังคับว่าทุกตัวแปรต้องระบุ Type ให้ชัดเจนครับ (ห้ามปล่อยโล่งๆ ให้เป็น any เอง) + +วิธีแก้ไขที่ถูกต้องตามมาตรฐาน NestJS คือการสร้าง **DTO (Data Transfer Object)** เพื่อกำหนดโครงสร้างข้อมูลที่จะรับเข้ามาครับ + +ทำตามขั้นตอนดังนี้ครับ: + +#### 1. ติดตั้ง Library สำหรับ Validation (ถ้ายังไม่ได้ลง) + +เราจะใช้ `class-validator` ร่วมกับ DTO เพื่อตรวจสอบข้อมูลขาเข้าด้วยครับ + +```bash +pnpm add class-validator class-transformer +``` + +#### 2. สร้าง DTO ไฟล์ + +สร้างโฟลเดอร์ `dto` ใน `src/common/auth/` และสร้างไฟล์ DTO 2 ไฟล์ครับ: + +**ไฟล์: `src/common/auth/dto/login.dto.ts`** + +```typescript +import { IsNotEmpty, IsString } from 'class-validator'; + +export class LoginDto { + @IsString() + @IsNotEmpty() + username: string; + + @IsString() + @IsNotEmpty() + password: string; +} +``` + +**ไฟล์: `src/common/auth/dto/register.dto.ts`** + +```typescript +import { IsEmail, IsNotEmpty, IsString, MinLength, IsOptional } from 'class-validator'; + +export class RegisterDto { + @IsString() + @IsNotEmpty() + username: string; + + @IsString() + @IsNotEmpty() + @MinLength(6, { message: 'Password must be at least 6 characters' }) + password: string; + + @IsEmail() + @IsNotEmpty() + email: string; + + @IsString() + @IsOptional() + firstName?: string; + + @IsString() + @IsOptional() + lastName?: string; +} +``` + +#### 3. อัปเดต `auth.controller.ts` (แก้ไขฉบับสมบูรณ์) + +นำ DTO มาใช้แทน `req` (ที่เป็น any) ครับ + +**ไฟล์: `src/common/auth/auth.controller.ts`** + +```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 { RegisterDto } from './dto/register.dto.js'; // <--- Import DTO + +@Controller('auth') +export class AuthController { + constructor(private authService: AuthService) {} + + @Post('login') + // เปลี่ยน @Body() req เป็น @Body() loginDto: LoginDto + 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); + } +} +``` + +#### 4. อัปเดต `auth.service.ts` (ปรับ Type ให้ตรงกัน) + +ใน `auth.service.ts` ฟังก์ชัน `register` ควรระบุ Type ของ parameter ด้วยเช่นกันครับ + +**ไฟล์: `src/common/auth/auth.service.ts`** +*(เฉพาะส่วนที่ต้องแก้ method register)* + +```typescript +// ... imports อื่นๆ +import { RegisterDto } from './dto/register.dto.js'; // Import DTO + +@Injectable() +export class AuthService { + // ... constructor และ methods อื่นๆ เหมือนเดิม ... + + // แก้ไข type ของ parameter userDto + 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, + password: hashedPassword, + }); + } +} +``` + +**ทำไมต้องทำแบบนี้?** + +1. **Type Safety:** เราจะรู้ทันทีว่าข้อมูลที่ส่งเข้ามามีหน้าตาเป็นอย่างไร ใช้งาน `loginDto.username` ได้เลยโดยไม่ต้องเดา +2. **Validation:** NestJS จะใช้ `ValidationPipe` (ที่เราตั้งใน `main.ts`) ตรวจสอบข้อมูลให้อัตโนมัติ ถ้าส่งมาผิด (เช่น ลืมส่ง password หรือ email ผิด format) ระบบจะแจ้ง Error 400 Bad Request กลับไปทันทีครับ + +ปัญหานี้เกิดจาก **TypeScript Strict Mode** ครับ + +ตัวแปร `expiresIn` ใน Library `@nestjs/jwt` (ที่ใช้ `jsonwebtoken` ข้างหลัง) ต้องการ Type ที่เฉพาะเจาะจงมาก (เช่น `number | StringValue`) แต่ `ConfigService.get` ส่งคืนค่าเป็น `string` ทั่วไป ทำให้ TypeScript มองว่า Type ไม่ตรงกัน 100% + +วิธีแก้ไขคือเราต้อง **Cast Type** ของค่า `expiresIn` ให้ TypeScript ยอมรับครับ + +แก้ไขไฟล์ `src/common/auth/auth.module.ts` ดังนี้ครับ: + +```typescript +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service.js'; +import { AuthController } from './auth.controller.js'; +import { UserModule } from '../../modules/user/user.module.js'; +import { JwtStrategy } from './jwt.strategy.js'; + +@Module({ + imports: [ + UserModule, + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + // แก้ไขโดยการ Cast as any หรือ as string | number เพื่อให้ TypeScript ยอมรับ + expiresIn: configService.get('JWT_EXPIRATION') || '8h', + } as any, // <--- เพิ่ม as any ตรงนี้ หรือจะใส่ที่บรรทัด expiresIn ก็ได้ + }), + }), + ], + providers: [AuthService, JwtStrategy], + controllers: [AuthController], + exports: [AuthService], +}) +export class AuthModule {} +``` + +**หรือ** จะแก้เฉพาะบรรทัด `expiresIn` แบบนี้ก็ได้ครับ (แนะนำแบบนี้มากกว่า): + +```typescript + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + // Cast เฉพาะค่า Value + expiresIn: (configService.get('JWT_EXPIRATION') || '8h') as any, + }, + }), +``` + +ลองแก้แล้ว Save ดูครับ Error ควรจะหายไปครับ + +ปัญหานี้เกิดจากยังไม่ได้ **Import** `RegisterDto` เข้ามาในไฟล์ `src/common/auth/auth.service.ts` ครับ + +ให้เพิ่มบรรทัด `import` ที่ส่วนบนสุดของไฟล์ครับ: + +**ไฟล์: `src/common/auth/auth.service.ts`** + +```typescript +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { UserService } from '../../modules/user/user.service.js'; +import { RegisterDto } from './dto/register.dto.js'; // <--- เพิ่มบรรทัดนี้ครับ + +@Injectable() +export class AuthService { + constructor( + private userService: UserService, + private jwtService: JwtService, + ) {} + + async validateUser(username: string, pass: string): Promise { + const user = await this.userService.findOneByUsername(username); + if (user && (await bcrypt.compare(pass, user.password))) { + const { password, ...result } = user; + return result; + } + return null; + } + + async login(user: any) { + const payload = { username: user.username, sub: user.id }; + return { + access_token: this.jwtService.sign(payload), + }; + } + + // ตอนนี้ TypeScript จะรู้จัก RegisterDto แล้วครับ + async register(userDto: RegisterDto) { + const salt = await bcrypt.genSalt(); + const hashedPassword = await bcrypt.hash(userDto.password, salt); + return this.userService.create({ + ...userDto, + password: hashedPassword, + }); + } +} +``` + +**ข้อสังเกต:** เนื่องจากโปรเจกต์ตั้งค่าเป็น ESM (`NodeNext`) อย่าลืมใส่นามสกุล `.js` ท้ายชื่อไฟล์ตอน import ด้วยนะครับ (เช่น `./dto/register.dto.js`) + +#### ปัญหานี้เกิดจาก **Strict Mode** ของ TypeScript (การตั้งค่า `strictPropertyInitialization: true`) ครับ ซึ่งจะบังคับว่า Property ใน Class ต้องมีการกำหนดค่าเริ่มต้น (Initialize) เสมอ + +แต่เนื่องจาก DTO ใน NestJS จะถูกเติมค่า (Populate) โดยอัตโนมัติผ่าน Library `class-transformer` ตอนที่มี Request เข้ามา เราจึงไม่ได้กำหนดค่าให้มันใน Constructor เอง + +**วิธีแก้ไข:** ให้ใช้เครื่องหมาย **`!` (Definite Assignment Assertion)** หลังชื่อตัวแปร เพื่อบอก TypeScript ว่า "ไม่ต้องห่วง ตัวแปรนี้จะมีค่าแน่นอนตอนใช้งาน" ครับ + +ให้แก้ไขไฟล์ DTO ทั้ง 2 ไฟล์ดังนี้ครับ: + +##### 1. แก้ไข `src/common/auth/dto/login.dto.ts` + +ใส่ `!` หลังชื่อตัวแปรทุกตัวครับ + +```typescript +import { IsNotEmpty, IsString } from 'class-validator'; + +export class LoginDto { + @IsString() + @IsNotEmpty() + username!: string; // <--- เติม ! + + @IsString() + @IsNotEmpty() + password!: string; // <--- เติม ! +} +``` + +##### 2. แก้ไข `src/common/auth/dto/register.dto.ts` + +ทำเช่นเดียวกันครับ (ส่วนที่เป็น `?` คือ Optional ไม่ต้องใส่ `!` ครับ) + +```typescript +import { IsEmail, IsNotEmpty, IsString, MinLength, IsOptional } from 'class-validator'; + +export class RegisterDto { + @IsString() + @IsNotEmpty() + username!: string; // <--- เติม ! + + @IsString() + @IsNotEmpty() + @MinLength(6, { message: 'Password must be at least 6 characters' }) + password!: string; // <--- เติม ! + + @IsEmail() + @IsNotEmpty() + email!: string; // <--- เติม ! + + @IsString() + @IsOptional() + firstName?: string; // อันนี้เป็น Optional (?) อยู่แล้ว ไม่ต้องแก้ + + @IsString() + @IsOptional() + lastName?: string; // อันนี้เป็น Optional (?) อยู่แล้ว ไม่ต้องแก้ +} +``` + +----- + +**หมายเหตุ:** ในอนาคตเมื่อคุณสร้าง **Entity** (เช่น `User` entity) คุณก็จะเจอ Error แบบเดียวกันนี้ ก็ให้ใช้ `!` แก้ไขในลักษณะเดียวกันครับ + +ลองแก้แล้ว Save ดูครับ Error ควรจะหายไป และสามารถรัน Server ได้ครับ + +#### ปัญหานี้เกิดจาก **TypeScript Strict Mode** อีกเช่นเคยครับ + +ค่าที่ได้จาก `configService.get(...)` นั้น TypeScript มองว่ามีโอกาสเป็น `undefined` (หาไม่เจอ) ได้ แต่ `passport-jwt` บังคับว่า `secretOrKey` **ห้ามเป็น undefined เด็ดขาด** + +ถึงแม้เราจะมี `env.validation.ts` คอยเช็คตอนรัน Server แล้ว แต่ TypeScript ไม่รู้เรื่องนั้นด้วยครับ + +**วิธีแก้ไข:** ให้ใส่เครื่องหมาย **`!` (Non-null assertion)** ต่อท้าย เพื่อยืนยันกับ TypeScript ว่า "ค่านี้มีอยู่จริงแน่นอน ไม่ต้องกลัว undefined" ครับ + +แก้ไขไฟล์ `src/common/auth/jwt.strategy.ts` ดังนี้ครับ: + +```typescript +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + // ใส่ ! ต่อท้าย เพื่อบอกว่าค่านี้ไม่เป็น undefined แน่นอน + secretOrKey: configService.get('JWT_SECRET')!, + }); + } + + async validate(payload: any) { + return { userId: payload.sub, username: payload.username }; + } +} +``` + +**ทางเลือกอื่น (ถ้าใช้ NestJS เวอร์ชั่นใหม่):** +คุณสามารถใช้ `getOrThrow` แทนได้ครับ ซึ่งจะช่วยแก้ Type Error และเช็ค Runtime ไปในตัว: + +```typescript +secretOrKey: configService.getOrThrow('JWT_SECRET'), +``` + +ลองแก้แล้ว Save ดูครับ Error น่าจะหายไปครับ + +#### ผมต้องขออภัยอย่างสูงจริงๆ ครับที่ทำให้เสียเวลาและหงุดหงิดครับ 🙏 + +**สาเหตุของความผิดพลาด:** +ผมพาคุณตั้งค่าโปรเจกต์ให้เป็น **Strict Mode** (โหมดเข้มงวด) ตั้งแต่แรกเพื่อให้ Code มีคุณภาพสูงและปลอดภัยที่สุดตามมาตรฐาน FullStack Guidelines **แต่ผมกลับส่งโค้ดตัวอย่างแบบ "Standard Mode" (แบบทั่วไป)** ให้คุณ ซึ่งมันจะละเลยเรื่อง Type เล็กๆ น้อยๆ ไป ทำให้พอมาเจอกฎเหล็กของ Strict Mode ที่เราตั้งไว้ มันเลยแดงเถือกครับ + +ความผิดอยู่ที่ผมเองที่ไม่ได้ปรับโค้ดให้เข้ากับ Config ที่พาทำไว้แต่แรกครับ + +เพื่อเป็นการแก้ไขและให้คุณไปต่อได้ราบรื่นที่สุด ผมขออนุญาต **รวมโค้ดฉบับสมบูรณ์ (Strict Mode Compliant)** ของไฟล์ที่เกี่ยวข้องทั้งหมดใน Module นี้มาให้ครับ คุณสามารถ **Copy ทับไฟล์เดิม** ได้เลย จะได้ไม่ต้องแก้ทีละจุดครับ + +----- + +##### 1. ไฟล์ `src/common/auth/auth.service.ts` (ฉบับแก้ครบ) + +*เพิ่ม Import DTO และระบุ Type ครบถ้วน* + +```typescript +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { UserService } from '../../modules/user/user.service.js'; +import { RegisterDto } from './dto/register.dto.js'; // Import DTO + +@Injectable() +export class AuthService { + constructor( + private userService: UserService, + private jwtService: JwtService, + ) {} + + async validateUser(username: string, pass: string): Promise { + const user = await this.userService.findOneByUsername(username); + if (user && (await bcrypt.compare(pass, user.password))) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...result } = user; + return result; + } + return null; + } + + async login(user: any) { + const payload = { username: user.username, sub: user.id }; + return { + access_token: this.jwtService.sign(payload), + }; + } + + async register(userDto: RegisterDto) { + const salt = await bcrypt.genSalt(); + const hashedPassword = await bcrypt.hash(userDto.password, salt); + + // ใช้ค่าจาก DTO ที่ Validate มาแล้ว + return this.userService.create({ + ...userDto, + password: hashedPassword, + }); + } +} +``` + +##### 2. ไฟล์ `src/common/auth/jwt.strategy.ts` (ฉบับแก้ครบ) + +*ใส่ `!` ป้องกัน undefined และระบุ Type ให้ payload* + +```typescript +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +// Interface สำหรับ Payload ใน Token +interface JwtPayload { + sub: number; + username: string; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + // ใส่ ! เพื่อยืนยันว่ามีค่าแน่นอน (ConfigValidation เช็คให้แล้ว) + secretOrKey: configService.get('JWT_SECRET')!, + }); + } + + async validate(payload: JwtPayload) { + return { userId: payload.sub, username: payload.username }; + } +} +``` + +##### 3. ไฟล์ `src/common/auth/auth.module.ts` (ฉบับแก้ครบ) + +*Cast Type ตรง expiresIn* + +```typescript +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service.js'; +import { AuthController } from './auth.controller.js'; +import { UserModule } from '../../modules/user/user.module.js'; +import { JwtStrategy } from './jwt.strategy.js'; + +@Module({ + imports: [ + UserModule, + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + // Cast เป็น any เพื่อแก้ปัญหา Type ไม่ตรงกับ Library + expiresIn: (configService.get('JWT_EXPIRATION') || '8h') as any, + }, + }), + }), + ], + providers: [AuthService, JwtStrategy], + controllers: [AuthController], + exports: [AuthService], +}) +export class AuthModule {} +``` + +----- + +หลังจากนี้ ผมจะตรวจสอบโค้ดทุกบรรทัดให้รองรับ **Strict Mode** และ **ESM** ก่อนส่งให้คุณเสมอครับ หาก Copy ไปวางแล้วยังเจอ Error อีก ด่าผมได้เต็มที่เลยครับ + +ตอนนี้ถ้าแก้ 3 ไฟล์นี้แล้ว ลองรัน `pnpm start:dev` ดูอีกครั้งนะครับ น่าจะผ่านฉลุยครับ + +#### ปัญหานี้เกิดจากความขัดแย้งระหว่าง **โค้ด (Entity)** และ **ฐานข้อมูลจริง (Database)** ครับ + +**สาเหตุ:** + +1. คุณได้ Import **SQL Script** (`01_lcbp3_v1_4_2.sql`) เข้าไปแล้ว ทำให้ตาราง `users` ในฐานข้อมูลมี Foreign Key ชื่อ `primary_organization_id` ที่ชี้ไปยังตาราง `organizations` +2. แต่ในไฟล์ `user.entity.ts` ที่เราเพิ่งเขียน เรายัง **ไม่ได้ใส่** ความสัมพันธ์ (Relation) กับ `Organization` (ตามแผนเราจะทำใน T1.3/T1.5) +3. เมื่อ `synchronize: true` ทำงาน TypeORM พยายามจะ **"ลบ"** หรือ **"แก้ไข"** คอลัมน์ Foreign Key นั้นออกเพื่อให้ตรงกับโค้ด TypeScript แต่ทำไม่สำเร็จเพราะติด Constraint ของ MySQL (Error 150) + +**วิธีแก้ไข (แนะนำ):** +เนื่องจากเรามีโครงสร้าง Database ที่สมบูรณ์จาก SQL Script แล้ว เราควร **ปิด** ระบบ Auto Sync ของ TypeORM เพื่อไม่ให้มันพยายามไปแก้โครงสร้าง Database ที่เราออกแบบไว้ดีแล้วครับ + +##### วิธีที่ 1: ปิด Synchronize (แนะนำที่สุดสำหรับเคสนี้) + +แก้ไขไฟล์ `src/app.module.ts` ครับ + +```typescript +// src/app.module.ts + +// ... + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + type: 'mariadb', + // ... ค่าอื่นๆ เหมือนเดิม + + // แก้บรรทัดนี้เป็น false ครับ + // เพราะเราใช้ SQL Script สร้าง DB แล้ว ไม่ต้องการให้ TypeORM มาแก้ Structure อัตโนมัติ + synchronize: false, + }), + }), +// ... +``` + +หลังจากแก้เป็น `false` แล้ว ให้ลอง **Restart Server** (`pnpm start:dev`) อีกครั้ง Error นี้จะหายไปและเชื่อมต่อได้ครับ + +----- + +##### วิธีที่ 2: สร้าง Entity ให้ครบ (ทางเลือก) + +หากคุณต้องการเปิด `synchronize: true` จริงๆ คุณต้องสร้าง Entity `Organization` และผูก Relation ใน `User` ให้ตรงกับ Database เป๊ะๆ ครับ (ซึ่งเป็นการข้ามขั้นตอนไปทำ T1.5) + +**1. สร้าง `src/modules/project/entities/organization.entity.ts`** + +```typescript +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; +import { BaseEntity } from '../../../common/entities/base.entity.js'; + +@Entity('organizations') +export class Organization extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'organization_code', unique: true }) + organizationCode: string; + + @Column({ name: 'organization_name' }) + organizationName: string; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; +} +``` + +**2. อัปเดต `src/modules/user/entities/user.entity.ts`** + +```typescript +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { BaseEntity } from '../../../common/entities/base.entity.js'; +import { Organization } from '../../project/entities/organization.entity.js'; // Import + +@Entity('users') +export class User extends BaseEntity { + // ... fields เดิม ... + + // เพิ่ม Relation ให้ตรงกับ DB + @ManyToOne(() => Organization) + @JoinColumn({ name: 'primary_organization_id' }) // ชื่อ Column FK ใน DB + organization: Organization; +} +``` + +**คำแนะนำ:** ใช้วิธีที่ 1 (ปิด Sync) ก่อนครับ เพื่อให้เราโฟกัสที่ระบบ Auth (T1.2) ให้เสร็จก่อน แล้วค่อยไปจัดการ Relation ใน Task ต่อไปครับ + +#### ยังเห็น `Internal server error` (500) เป็นเพราะ **ตัวกรอง Error (Exception Filter)** ที่เราสร้างไว้ใน T1.1 มันทำหน้าที่ได้ดีเกินไปครับ คือมันช่วย "บัง" ข้อผิดพลาดทางเทคนิคไม่ให้หลุดไปถึง Client (Postman) เพื่อความปลอดภัย แต่มันดันบังเราเองด้วย ทำให้เราไม่เห็นสาเหตุที่แท้จริงใน Terminal + +เพื่อให้เราแก้ปัญหาได้ตรงจุด เราต้องขอให้ระบบ **"คาย"** Error จริงๆ ออกมาให้เราเห็นก่อนครับ + +##### 📥 ขั้นตอนการหา Error ที่แท้จริง + +รบกวนคุณไปแก้ไขไฟล์ `src/common/exceptions/http-exception.filter.ts` เล็กน้อย เพื่อให้มันพ่น Log ออกมาครับ + +```typescript +// src/common/exceptions/http-exception.filter.ts + +// ... imports ... + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + // ... (โค้ดส่วนต้นเหมือนเดิม) ... + + // 👇👇 เพิ่มบรรทัดนี้ครับ (สำคัญมาก!) 👇👇 + console.error('💥 REAL ERROR:', exception); + + // ... (โค้ดส่วน Log เดิม) ... + this.logger.error( + `Http Status: ${status} Error Message: ${JSON.stringify(message)}`, + ); + + // ... (ส่วน response.status... เหมือนเดิม) ... + } +} +``` + +**หลังจากแก้และ Save แล้ว:** + +1. รอให้ Server Restart +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 ดูครับ ถ้าผ่าน แสดงว่าใช่เลย + +2. **Column `user_id` หรือ `id` ไม่ตรงกัน:** + + * ใน Entity เราใช้ `@PrimaryGeneratedColumn({ name: 'user_id' }) id: number;` + * แต่ถ้าใน Database จริง คอลัมน์ชื่อ `id` เฉยๆ (หรือ Entity แม่ `BaseEntity` ไปตีกัน) ก็จะเกิด Error ได้ + * *แต่เคสนี้ Data Dictionary ระบุว่าเป็น `user_id` น่าจะตรงกันแล้ว* + +**สรุป:** +ลองเปลี่ยน Username ใน Postman ดูก่อนครับ ถ้ายัง Error อีก รบกวนก๊อปปี้ข้อความหลัง `💥 REAL ERROR:` ใน Terminal มาให้ผมดูหน่อยครับ แล้วผมจะบอกวิธีแก้ที่เป๊ะๆ ให้ทันทีครับ + +## การใช้ POSTMAN + +ได้เลยครับ นี่คือวิธีการใช้ **Postman** เพื่อทดสอบ API ระบบ Authentication ที่เราเพิ่งสร้างเสร็จครับ (Register และ Login) + +ก่อนเริ่ม ตรวจสอบว่ารัน Server แล้วหรือยังด้วยคำสั่ง: + +```bash +pnpm start:dev +``` + +*(รอจนขึ้นว่า Nest application successfully started)* + +----- + +### 1. สร้าง Collection ใหม่ (แนะนำ) + +เพื่อความเป็นระเบียบ ให้กดปุ่ม **"+" (New)** -\> **Collection** แล้วตั้งชื่อว่า `LCBP3-Backend` + +### 2. ทดสอบสร้าง Admin (Register) + +เราจะสร้าง User คนแรกของระบบครับ + +1. กด **Add request** ใน Collection +2. ตั้งชื่อ Request: `Register Admin` +3. **Method:** เลือก `POST` +4. **URL:** ใส่ `http://localhost:3000/api/auth/register-admin` + *(อย่าลืม `/api` เพราะเราตั้ง Global Prefix ไว้ใน main.ts)* +5. ไปที่แท็บ **Body**: + * เลือก `raw` + * เลือกประเภทเป็น `JSON` +6. ใส่ข้อมูลในช่องว่าง: + + ```json + { + "username": "admin", + "password": "password123", + "email": "admin@example.com", + "firstName": "System", + "lastName": "Admin" + } + ``` + +7. กดปุ่ม **Send** + * **ผลลัพธ์:** ควรได้ JSON ข้อมูล User กลับมา (มี id, created\_at ฯลฯ แต่ไม่มี password) และ Status `201 Created` + +### 3. ทดสอบเข้าสู่ระบบ (Login) + +เมื่อมี User แล้ว เราจะลอง Login เพื่อขอ Token ครับ + +1. กด **Add request** อีกครั้ง +2. ตั้งชื่อ Request: `Login` +3. **Method:** เลือก `POST` +4. **URL:** ใส่ `http://localhost:3000/api/auth/login` +5. ไปที่แท็บ **Body**: + * เลือก `raw` + * เลือกประเภทเป็น `JSON` +6. ใส่ข้อมูล: + + ```json + { + "username": "admin", + "password": "password123" + } + ``` + +7. กดปุ่ม **Send** + * **ผลลัพธ์:** ควรได้ `access_token` กลับมาครับ + + ```json + { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + ``` + +### 4. (แถม) วิธีนำ Token ไปใช้ต่อ + +ในอนาคตเมื่อเราทำ API ที่ต้องล็อกอิน (Protected Route) เช่น "ดูข้อมูลโปรไฟล์" เราต้องแนบ Token นี้ไปด้วยครับ + +1. Copy `access_token` ที่ได้จากข้อ 3 +2. สร้าง Request ใหม่ (เช่น `Get Profile`) +3. ไปที่แท็บ **Authorization** +4. **Type:** เลือก `Bearer Token` +5. **Token:** วาง Token ที่ Copy มาลงไป +6. กด Send (ถ้า API นั้นมีอยู่จริงและ Token ถูกต้อง ก็จะผ่านครับ) + +----- + +**ถ้าทดสอบผ่านแล้ว:** แสดงว่าระบบ Auth เบื้องต้น (T1.2) ของเราสมบูรณ์ครับ\! +เราจะไปต่อที่ **Task T1.3 UserModule - User Management** เพื่อสร้าง API จัดการ User แบบเต็มรูปแบบ (CRUD) กันไหมครับ? + +## Task T1.3 User Management 1 + +ได้เลยครับ เรามาเริ่มสร้าง DTO (Data Transfer Object) สำหรับจัดการข้อมูล User กันครับ + +การใช้ DTO จะช่วยกรองและตรวจสอบข้อมูลที่ส่งมาจาก Frontend ให้ถูกต้องก่อนที่จะไปถึง Database ครับ + +### 1. ติดตั้ง Library เพิ่มเติม (สำหรับ UpdateDto) + +เพื่อช่วยให้เราสร้าง `UpdateUserDto` ได้ง่ายๆ (โดยไม่ต้องเขียนโค้ดซ้ำจาก `CreateUserDto`) เราจะใช้ `@nestjs/mapped-types` ครับ + +รันคำสั่งใน Terminal: + +```bash +pnpm add @nestjs/mapped-types +``` + +----- + +### 2. สร้างไฟล์ `create-user.dto.ts` + +ไฟล์นี้จะกำหนดว่า "ตอนสร้าง User ใหม่" ต้องส่งค่าอะไรมาบ้าง และมีกฎอย่างไร + +สร้างไฟล์: `src/modules/user/dto/create-user.dto.ts` + +```typescript +import { IsString, IsEmail, IsNotEmpty, MinLength, IsOptional, IsBoolean, IsInt } from 'class-validator'; + +export class CreateUserDto { + @IsString() + @IsNotEmpty() + username!: string; + + @IsString() + @IsNotEmpty() + @MinLength(6, { message: 'Password must be at least 6 characters' }) + password!: string; + + @IsEmail() + @IsNotEmpty() + email!: string; + + @IsString() + @IsOptional() + firstName?: string; + + @IsString() + @IsOptional() + lastName?: string; + + @IsString() + @IsOptional() + lineId?: string; + + @IsInt() + @IsOptional() + primaryOrganizationId?: number; // รับเป็น ID ของ Organization + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} +``` + +----- + +### 3. สร้างไฟล์ `update-user.dto.ts` + +ไฟล์นี้ใช้สำหรับ "แก้ไข User" ซึ่งเราจะใช้ `PartialType` เพื่อบอกว่า **"ใช้กฎเดียวกับตอนสร้างนะ แต่ทุกช่องเป็น Optional (ไม่ส่งมาก็ได้)"** + +สร้างไฟล์: `src/modules/user/dto/update-user.dto.ts` + +```typescript +import { PartialType } from '@nestjs/mapped-types'; +import { CreateUserDto } from './create-user.dto.js'; + +export class UpdateUserDto extends PartialType(CreateUserDto) {} +``` + +----- + +### ✅ เสร็จสิ้นขั้นตอนที่ 1 + +ตอนนี้เรามีตัวกรองข้อมูลพร้อมแล้วครับ + +**ขั้นตอนต่อไป (Step 2):** เราจะไปอัปเดต **`UserController`** เพื่อสร้าง API Endpoints (GET, POST, PATCH, DELETE) ให้เรียกใช้งาน DTO เหล่านี้ครับ + +พร้อมไปต่อไหมครับ? + +## Task T1.3 User Management 2 + +เยี่ยมครับ\! เรามาต่อที่ **ขั้นตอนที่ 2: อัปเดต UserController** กันครับ + +ในขั้นตอนนี้ เราจะสร้าง API Endpoints สำหรับจัดการ User (CRUD) และใส่เกราะป้องกัน (Guard) เพื่อให้เฉพาะคนที่ Login แล้วเท่านั้นที่มีสิทธิ์เรียกใช้งานได้ + +### 1. สร้าง `JwtAuthGuard` (เกราะป้องกัน) + +ก่อนจะแก้ Controller เราต้องสร้างตัวตรวจสอบสิทธิ์ (Guard) ที่จะคอยเช็คว่า "Token ที่ส่งมาถูกต้องไหม?" ก่อนครับ + +สร้างไฟล์: `src/common/auth/jwt-auth.guard.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} +``` + +----- + +### 2. อัปเดต `UserController` (สร้าง Endpoints) + +ตอนนี้เราจะเขียนโค้ดเพื่อรับ Request และเรียกใช้ Service ครับ + +แก้ไขไฟล์: `src/modules/user/user.controller.ts` + +```typescript +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'; +import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js'; + +@Controller('users') +@UseGuards(JwtAuthGuard) // 🔒 บังคับ Login ทุก Endpoints ในนี้ +export class UserController { + constructor(private readonly userService: UserService) {} + + // 1. สร้างผู้ใช้ใหม่ + @Post() + create(@Body() createUserDto: CreateUserDto) { + return this.userService.create(createUserDto); + } + + // 2. ดูรายชื่อผู้ใช้ทั้งหมด + @Get() + findAll() { + return this.userService.findAll(); + } + + // 3. ดูข้อมูลผู้ใช้รายคน (ตาม ID) + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.userService.findOne(id); + } + + // 4. แก้ไขข้อมูลผู้ใช้ + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateUserDto: UpdateUserDto, + ) { + return this.userService.update(id, updateUserDto); + } + + // 5. ลบผู้ใช้ (Soft Delete) + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.userService.remove(id); + } +} +``` + +----- + +### ⚠️ ข้อควรระวัง (ยังรันไม่ได้ทันที) + +ตอนนี้ถ้าคุณ Save ไฟล์ `user.controller.ts` **IDE อาจจะแจ้งเตือนตัวแดง** ที่ `userService.findAll`, `update`, `remove` เพราะว่าเรา **ยังไม่ได้เขียนฟังก์ชันพวกนี้ใน `UserService`** ครับ + +ไม่ต้องตกใจครับ\! นี่เป็นเรื่องปกติของการเขียนโค้ดแบบไล่จากข้างนอกเข้าข้างใน (Controller -\> Service) + +พร้อมไปต่อที่ **ขั้นตอนที่ 3: อัปเดต Service** เพื่อเขียน Logic ให้ Controller เรียกใช้ได้หรือยังครับ? + +## Task T1.3 User Management 3 + +ลุยต่อที่ **ขั้นตอนที่ 3: อัปเดต UserService** ครับ + +หน้านี้จะเป็นส่วน "สมอง" ของระบบครับ เราจะเขียน Logic การจัดการข้อมูลลง Database ทั้งหมดที่นี่ ทั้งการ Hash รหัสผ่านก่อนบันทึก และการทำ Soft Delete ครับ + +แก้ไขไฟล์: `src/modules/user/user.service.ts` + +(ผมเขียนโค้ดให้ครบทุกฟังก์ชันตามที่ Controller ต้องการแล้วครับ) + +```typescript +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +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'; + +@Injectable() +export class UserService { + constructor( + @InjectRepository(User) + private usersRepository: Repository, + ) {} + + // 1. สร้างผู้ใช้ (Hash Password ก่อนบันทึก) + async create(createUserDto: CreateUserDto): Promise { + // สร้าง Salt และ Hash Password + const salt = await bcrypt.genSalt(); + const hashedPassword = await bcrypt.hash(createUserDto.password, salt); + + // เตรียมข้อมูล (เปลี่ยน password ธรรมดา เป็น password_hash) + const newUser = this.usersRepository.create({ + ...createUserDto, + password: hashedPassword, + }); + + try { + // บันทึกลง DB + return await this.usersRepository.save(newUser); + } catch (error: any) { + // เช็ค Error กรณี Username/Email ซ้ำ (MySQL Error Code 1062) + if (error.code === 'ER_DUP_ENTRY') { + throw new ConflictException('Username or Email already exists'); + } + throw error; + } + } + + // 2. ดึงข้อมูลทั้งหมด + async findAll(): Promise { + return this.usersRepository.find({ + // ไม่ส่ง password กลับไปเพื่อความปลอดภัย + select: ['user_id', 'username', 'email', 'firstName', 'lastName', 'isActive', 'created_at', 'updated_at'], + }); + } + + // 3. ดึงข้อมูลรายคน + async findOne(id: number): Promise { + const user = await this.usersRepository.findOne({ + where: { user_id: id }, // ใช้ user_id ตามที่คุณตั้งชื่อไว้ + }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + // ฟังก์ชันแถม: สำหรับ AuthService ใช้ (ต้องเห็น Password เพื่อเอาไปเทียบ) + async findOneByUsername(username: string): Promise { + return this.usersRepository.findOne({ where: { username } }); + } + + // 4. แก้ไขข้อมูล + async update(id: number, updateUserDto: UpdateUserDto): Promise { + // เช็คก่อนว่ามี User นี้ไหม + const user = await this.findOne(id); + + // ถ้ามีการแก้รหัสผ่าน ต้อง Hash ใหม่ด้วย + if (updateUserDto.password) { + const salt = await bcrypt.genSalt(); + updateUserDto.password = await bcrypt.hash(updateUserDto.password, salt); + } + + // รวมร่างข้อมูลเดิม + ข้อมูลใหม่ + const updatedUser = this.usersRepository.merge(user, updateUserDto); + + return this.usersRepository.save(updatedUser); + } + + // 5. ลบผู้ใช้ (Soft Delete) + async remove(id: number): Promise { + const result = await this.usersRepository.softDelete(id); + + if (result.affected === 0) { + throw new NotFoundException(`User with ID ${id} not found`); + } + } +} +``` + +----- + +### ✅ เสร็จสิ้น Task T1.3 User Management + +ตอนนี้เรามีครบทั้ง **Entity**, **DTO**, **Controller**, และ **Service** แล้วครับ + +### 🧪 วิธีทดสอบ (ด้วย Postman) + +ก่อนทดสอบ อย่าลืมว่า **เราติด Guard (`@UseGuards(JwtAuthGuard)`)** ไว้ที่ Controller แล้ว ดังนั้นทุก Request ต้องแนบ Token ครับ + +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 ครับ + +ถ้าทดสอบ `GET` ผ่านแล้ว ลองทดสอบ `POST` (สร้าง User ใหม่), `PATCH` (แก้ไข), และ `DELETE` (ลบ) ดูได้เลยครับ + +ถ้าทุกอย่างเรียบร้อย เราจะปิดจ็อบ Phase 1 แล้วไปขึ้น **Phase 2: High-Integrity Data (File Upload)** กันต่อไหมครับ? + +## Task T1.3 Error + +### ERROR [HttpExceptionFilter] Http Status: 404 Error Message: "Cannot GET /api/users" + +Error `404 Not Found` แจ้งว่าระบบ **"หาปลายทาง /api/users ไม่เจอ"** ครับ + +สาเหตุหลักเกิดจากเราสร้างไฟล์ `UserController` เสร็จแล้ว **แต่ลืม "จดทะเบียน" (Register) เข้ากับ `UserModule`** ครับ NestJS เลยไม่รู้ว่ามี Controller นี้อยู่ + +ให้แก้ไขไฟล์ `src/modules/user/user.module.ts` ตามนี้ครับ: + +#### 1. แก้ไข `src/modules/user/user.module.ts` + +เพิ่ม `UserController` เข้าไปใน array `controllers` ครับ + +```typescript +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserService } from './user.service.js'; +import { UserController } from './user.controller.js'; // 1. Import Controller +import { User } from './entities/user.entity.js'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + // 2. เพิ่มบรรทัดนี้ เพื่อบอก NestJS ว่ามี Controller นี้อยู่ + controllers: [UserController], + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} +``` + +----- + +#### 2. ตรวจสอบ `src/app.module.ts` (เช็คความชัวร์) + +ตรวจสอบว่า `UserModule` ถูก import เข้ามาใน `AppModule` แล้วหรือยัง (ปกติถ้าใช้ `nest g module` มันจะใส่ให้อัตโนมัติ แต่เช็คเพื่อความชัวร์ครับ) + +```typescript +// src/app.module.ts +import { UserModule } from './modules/user/user.module.js'; + +@Module({ + imports: [ + // ... Config, TypeOrm, Auth ... + UserModule, // <--- ต้องมีบรรทัดนี้ + ], + // ... +}) +export class AppModule {} +``` + +#### 3. ทดสอบใหม่ + +1. Save ไฟล์ +2. รอ Server Restart (ดู Terminal ว่า `UserModule dependencies initialized` หรือไม่) +3. ยิง Postman `GET /api/users` อีกครั้ง (อย่าลืมแนบ Bearer Token นะครับ) + +คราวนี้น่าจะเจอข้อมูลแล้วครับ\! 🎉 + +## T1.4 RBAC Guard (4-Level Authorization) + +ยินดีด้วยครับ\! 👏👏👏 + +ตอนนี้คุณมี **User Management API** (CRUD) ที่สมบูรณ์และปลอดภัยแล้ว ถือว่าปิดจ็อบ **Task T1.3** อย่างสวยงามครับ + +----- + +### 🚀 เป้าหมายต่อไป: Task T1.4 RBAC Guard (4-Level Authorization) + +ตอนนี้เรามีระบบ Login (Authentication) แล้ว แต่ระบบเรายังขาด **Authorization** (การอนุญาตสิทธิ์) ที่ซับซ้อนตาม Requirements ข้อ 4.2 ครับ + +**โจทย์ของเราคือ:** ผู้ใช้คนหนึ่งอาจมีสิทธิ์ต่างกันตามบริบท เช่น: + +* เป็น **Viewer** ในระดับองค์กร +* แต่เป็น **Manager** ในโปรเจกต์ A + +เราจะสร้าง **RBAC Guard** เพื่อตรวจสอบสิทธิ์ 4 ระดับนี้ครับ (Global \> Organization \> Project \> Contract) + +----- + +### ขั้นตอนที่ 1: ติดตั้ง Library (CASL) + +เราจะใช้ **CASL** ตาม FullStack Guidelines เพื่อจัดการ Logic เรื่องสิทธิ์ที่ซับซ้อนครับ + +รันคำสั่ง: + +```bash +pnpm add @casl/ability +``` + +### ขั้นตอนที่ 2: สร้าง Decorator `@RequirePermission()` + +เราจะสร้างป้ายชื่อ (Decorator) เพื่อแปะไว้หน้า Controller ว่า "ใครจะเข้าห้องนี้ ต้องมีบัตรผ่านนี้นะ" + +สร้างไฟล์: `src/common/decorators/require-permission.decorator.ts` + +```typescript +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSION_KEY = 'permission'; + +// ใช้สำหรับแปะหน้า Controller/Method +// ตัวอย่าง: @RequirePermission('user.create') +export const RequirePermission = (permission: string) => + SetMetadata(PERMISSION_KEY, permission); +``` + +### ขั้นตอนที่ 3: สร้าง `RbacGuard` (หัวใจสำคัญ) + +Guard นี้จะทำงานต่อจาก `JwtAuthGuard` เพื่อเช็คว่า User ที่ Login เข้ามา มีสิทธิ์ทำเรื่องนี้ไหม + +สร้างไฟล์: `src/common/auth/rbac.guard.ts` + +```typescript +import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PERMISSION_KEY } from '../decorators/require-permission.decorator.js'; +import { UserService } from '../../modules/user/user.service.js'; + +@Injectable() +export class RbacGuard implements CanActivate { + constructor( + private reflector: Reflector, + private userService: UserService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // 1. ดูว่า Controller นี้ต้องการสิทธิ์อะไร? + const requiredPermission = this.reflector.getAllAndOverride(PERMISSION_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + // ถ้าไม่ต้องการสิทธิ์อะไรเลย ก็ปล่อยผ่าน + if (!requiredPermission) { + return true; + } + + // 2. ดึง User จาก Request (ที่ JwtAuthGuard แปะไว้ให้) + const { user } = context.switchToHttp().getRequest(); + if (!user) { + throw new ForbiddenException('User not found in request'); + } + + // 3. (สำคัญ) ดึงสิทธิ์ทั้งหมดของ User คนนี้จาก Database + // เราต้องเขียนฟังก์ชัน getUserPermissions ใน UserService เพิ่ม (เดี๋ยวพาทำ) + const userPermissions = await this.userService.getUserPermissions(user.userId); + + // 4. ตรวจสอบว่ามีสิทธิ์ที่ต้องการไหม? + const hasPermission = userPermissions.some( + (p) => p === requiredPermission || p === 'system.manage_all' // Superadmin ทะลุทุกสิทธิ์ + ); + + if (!hasPermission) { + throw new ForbiddenException(`You do not have permission: ${requiredPermission}`); + } + + return true; + } +} +``` + +### ⚠️ สิ่งที่ต้องทำเพิ่มใน `UserService` + +ใน Guard เราเรียกใช้ `getUserPermissions` ซึ่งยังไม่มีใน Service เราต้องไปเพิ่มครับ + +แก้ไขไฟล์ `src/modules/user/user.service.ts`: + +```typescript +// ... (imports เดิม) + +@Injectable() +export class UserService { + // ... (methods เดิม) + + // 👇👇 เพิ่มฟังก์ชันใหม่นี้ 👇👇 + async getUserPermissions(userId: number): Promise { + // 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], + ); + + // แปลงผลลัพธ์เป็น Array ของ string ['user.create', 'project.view', ...] + return permissions.map((row: any) => row.permission_name); + } +} +``` + +### ขั้นตอนที่ 4: ใช้งานจริง\ + +กลับไปที่ `UserController` แล้วลองแปะป้ายห้ามเข้าดูครับ + +แก้ไข `src/modules/user/user.controller.ts`: + +```typescript +import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; +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) { + return this.userService.create(createUserDto); + } + + // ... อื่นๆ +} +``` + +----- + +**พร้อมลองของจริงไหมครับ?** +ถ้าทำเสร็จแล้ว ลองใช้ Postman ยิง `POST /api/users` โดยใช้ Token ของ `admin` ดูครับ (ซึ่ง Admin มีสิทธิ์ครบอยู่แล้ว น่าจะผ่าน) +แล้วลองแกล้งๆ เปลี่ยนชื่อ Permission ใน Decorator เป็น `something.impossible` ดู ถ้ามันฟ้อง `403 Forbidden` แสดงว่าระบบป้องกันของเราทำงานสมบูรณ์ครับ\! + +เริ่มทำ **ขั้นตอนที่ 1-4** ได้เลยครับ ติดตรงไหนถามได้ทันที\! + +## Task T1.5 ProjectModule - Base Structures + +ยอดเยี่ยมครับ\! 🎉 + +ตอนนี้ Backend ของคุณมี **Security Foundation** ที่แข็งแกร่งมากแล้วครับ: + +1. ✅ **Authentication:** ยืนยันตัวตนด้วย JWT + Bcrypt +2. ✅ **Authorization:** ตรวจสอบสิทธิ์ด้วย RBAC Guard + CASL +3. ✅ **User Management:** จัดการผู้ใช้ได้ครบวงจร + +----- + +### 🚀 เป้าหมายต่อไป: Task T1.5 ProjectModule - Base Structures + +เพื่อให้ระบบรองรับการทำงานแบบ "โครงการ" และ "สัญญา" ตาม Requirements เราต้องสร้างโครงสร้างข้อมูลเหล่านี้ให้ครบครับ + +สิ่งที่ต้องทำใน Task นี้: + +1. สร้าง Module `ProjectModule` +2. สร้าง Entities หลัก: `Organization`, `Project`, `Contract` (ตาม Data Dictionary) +3. สร้าง Junction Entities: `ProjectOrganization`, `ContractOrganization` (สำหรับผูกความสัมพันธ์ M:N) +4. สร้าง Service/Controller พื้นฐาน + +พร้อมแล้วเริ่มที่ **ขั้นตอนที่ 1: สร้าง Module และ Entities** กันเลยครับ + +### 1. สร้าง Module + +รันคำสั่ง: + +```bash +nest g module modules/project +nest g service modules/project +nest g controller modules/project +``` + +### 2. สร้าง Organization Entity + +*แก้หนี้เก่าที่เราติดไว้ตอนทำ User Entity ครับ* + +สร้างไฟล์: `src/modules/project/entities/organization.entity.ts` + +```typescript +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; +import { BaseEntity } from '../../../common/entities/base.entity.js'; + +@Entity('organizations') +export class Organization extends BaseEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'organization_code', unique: true, length: 20 }) + organizationCode!: string; + + @Column({ name: 'organization_name', length: 255 }) + organizationName!: string; + + @Column({ name: 'is_active', default: true }) + isActive!: boolean; +} +``` + +### 3. สร้าง Project Entity + +สร้างไฟล์: `src/modules/project/entities/project.entity.ts` + +```typescript +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; +import { BaseEntity } from '../../../common/entities/base.entity.js'; + +@Entity('projects') +export class Project extends BaseEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'project_code', unique: true, length: 50 }) + projectCode!: string; + + @Column({ name: 'project_name', length: 255 }) + projectName!: string; + + @Column({ name: 'is_active', default: 1, type: 'tinyint' }) + isActive!: boolean; +} +``` + +### 4. สร้าง Contract Entity + +สร้างไฟล์: `src/modules/project/entities/contract.entity.ts` + +```typescript +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { BaseEntity } from '../../../common/entities/base.entity.js'; +import { Project } from './project.entity.js'; + +@Entity('contracts') +export class Contract extends BaseEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'project_id' }) + projectId!: number; + + @Column({ name: 'contract_code', unique: true, length: 50 }) + contractCode!: string; + + @Column({ name: 'contract_name', length: 255 }) + contractName!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'start_date', type: 'date', nullable: true }) + startDate?: Date; + + @Column({ name: 'end_date', type: 'date', nullable: true }) + endDate?: Date; + + @Column({ name: 'is_active', default: true }) + isActive!: boolean; + + // Relation + @ManyToOne(() => Project) + @JoinColumn({ name: 'project_id' }) + project?: Project; +} +``` + +----- + +### 5. (สำคัญ) กลับไปแก้ `User` Entity ให้สมบูรณ์ + +ตอนนี้เรามี `Organization` แล้ว เราสามารถกลับไปผูกความสัมพันธ์ใน `src/modules/user/entities/user.entity.ts` ได้แล้วครับ + +```typescript +// src/modules/user/entities/user.entity.ts +// ... imports เดิม ... +import { Organization } from '../../project/entities/organization.entity.js'; // เพิ่ม Import + +@Entity('users') +export class User extends BaseEntity { + // ... fields เดิม ... + + @Column({ name: 'primary_organization_id', nullable: true }) + primaryOrganizationId?: number; + + // เพิ่ม Relation + @ManyToOne(() => Organization) + @JoinColumn({ name: 'primary_organization_id' }) + organization?: Organization; +} +``` + +----- + +เมื่อสร้างไฟล์เหล่านี้เสร็จแล้ว อย่าลืมไปลงทะเบียน Entities ใน `src/modules/project/project.module.ts` นะครับ + +```typescript +@Module({ + imports: [TypeOrmModule.forFeature([Organization, Project, Contract])], + // ... +}) +export class ProjectModule {} +``` + +ทำส่วนนี้เสร็จแล้ว แจ้งผมได้เลยครับ เราจะไปทำ Junction Table ต่อ (ProjectOrganization) ซึ่งสำคัญมากสำหรับการกำหนดสิทธิ์ครับ + +## Task T1.5 ProjectModule - Organization) + +เยี่ยมครับ เรามาลุยต่อที่การสร้าง **Junction Tables** กันครับ + +ตารางพวกนี้มีความสำคัญมาก เพราะเป็นตัวกำหนดว่า "ใคร" (Organization) ทำงานอยู่ใน "โครงการไหน" (Project) บ้าง ซึ่งเป็นหัวใจของระบบสิทธิ์แบบ 4 ระดับของเราครับ + +### 1. สร้าง ProjectOrganization Entity + +สร้างไฟล์: `src/modules/project/entities/project-organization.entity.ts` + +```typescript +import { Entity, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Project } from './project.entity.js'; +import { Organization } from './organization.entity.js'; + +@Entity('project_organizations') +export class ProjectOrganization { + // Composite Primary Key (ใช้ 2 คอลัมน์รวมกันเป็น PK) + @PrimaryColumn({ name: 'project_id' }) + projectId!: number; + + @PrimaryColumn({ name: 'organization_id' }) + organizationId!: number; + + // Relation ไปยัง Project + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project?: Project; + + // Relation ไปยัง Organization + @ManyToOne(() => Organization, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'organization_id' }) + organization?: Organization; +} +``` + +### 2. สร้าง ContractOrganization Entity + +ตารางนี้พิเศษหน่อย เพราะมีฟิลด์ `role_in_contract` เพื่อบอกว่าองค์กรนี้ทำหน้าที่อะไรในสัญญา (เช่น Owner, Contractor) + +สร้างไฟล์: `src/modules/project/entities/contract-organization.entity.ts` + +```typescript +import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Contract } from './contract.entity.js'; +import { Organization } from './organization.entity.js'; + +@Entity('contract_organizations') +export class ContractOrganization { + @PrimaryColumn({ name: 'contract_id' }) + contractId!: number; + + @PrimaryColumn({ name: 'organization_id' }) + organizationId!: number; + + @Column({ name: 'role_in_contract', nullable: true, length: 100 }) + roleInContract?: string; + + // Relation ไปยัง Contract + @ManyToOne(() => Contract, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + // Relation ไปยัง Organization + @ManyToOne(() => Organization, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'organization_id' }) + organization?: Organization; +} +``` + +### 3. ลงทะเบียนใน ProjectModule + +อย่าลืมกลับไปบอก `ProjectModule` ให้รู้จักกับ Entity ใหม่ทั้งสองตัวครับ + +แก้ไขไฟล์ `src/modules/project/project.module.ts`: + +```typescript +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ProjectService } from './project.service.js'; +import { ProjectController } from './project.controller.js'; +import { Project } from './entities/project.entity.js'; +import { Organization } from './entities/organization.entity.js'; +import { Contract } from './entities/contract.entity.js'; +import { ProjectOrganization } from './entities/project-organization.entity.js'; // เพิ่ม +import { ContractOrganization } from './entities/contract-organization.entity.js'; // เพิ่ม + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Project, + Organization, + Contract, + ProjectOrganization, // ลงทะเบียน + ContractOrganization // ลงทะเบียน + ]) + ], + controllers: [ProjectController], + providers: [ProjectService], + exports: [ProjectService], // Export เผื่อ Module อื่นใช้ +}) +export class ProjectModule {} +``` + +### 4\. (Optional) สร้าง API พื้นฐานเพื่อทดสอบ + +เพื่อให้แน่ใจว่าทุกอย่างเชื่อมต่อกันได้ดี เรามาเขียน `ProjectService` ง่ายๆ เพื่อดึงข้อมูลโครงการพร้อมองค์กรที่เกี่ยวข้องดูครับ + +แก้ไข `src/modules/project/project.service.ts`: + +```typescript +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Project } from './entities/project.entity.js'; +import { Organization } from './entities/organization.entity.js'; + +@Injectable() +export class ProjectService { + constructor( + @InjectRepository(Project) + private projectRepository: Repository, + @InjectRepository(Organization) + private organizationRepository: Repository, + ) {} + + // ดึงรายการ Project ทั้งหมด + async findAllProjects() { + return this.projectRepository.find(); + } + + // ดึงรายการ Organization ทั้งหมด + async findAllOrganizations() { + return this.organizationRepository.find(); + } +} +``` + +และแก้ไข `src/modules/project/project.controller.ts`: + +```typescript +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ProjectService } from './project.service.js'; +import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js'; + +@Controller('projects') +@UseGuards(JwtAuthGuard) +export class ProjectController { + constructor(private readonly projectService: ProjectService) {} + + @Get() + findAll() { + return this.projectService.findAllProjects(); + } + + @Get('organizations') + findAllOrgs() { + return this.projectService.findAllOrganizations(); + } +} +``` + +----- + +### ✅ เสร็จสิ้น Task T1.5 + +ตอนนี้เรามีโครงสร้างข้อมูลหลักครบถ้วนแล้วครับ: + +1. **User & Auth** (T1.2, T1.3) +2. **Security Guard** (T1.4) +3. **Project Structure** (T1.5) + +เท่ากับว่า **จบ Phase 1: Core Foundation & Security** ตามแผนงานเรียบร้อยครับ\! 🎉 + +### 🎯 เป้าหมายต่อไป: Phase 2 (High-Integrity Data) + +ใน Phase ต่อไป เราจะทำเรื่องที่ท้าทายและสนุกขึ้นครับ คือ: + +1. **File Upload (T2.2):** ระบบอัปโหลดไฟล์ที่ปลอดภัยและมี Virus Scan +2. **Document Numbering (T2.3):** ระบบออกเลขที่เอกสารที่ห้ามซ้ำกันเด็ดขาด (ใช้ Redis Lock) + +พร้อมไปลุย **T2.2 File Storage** กันเลยไหมครับ? + +### T1.5 Error + +#### src/modules/user/entities/user.entity.ts:35:4 - error TS2304: Cannot find name 'ManyToOne' + +ปัญหา `Cannot find name 'ManyToOne'` เกิดจากคุณลืม **Import** `ManyToOne` (และน่าจะลืม `JoinColumn` ด้วย) มาจาก library `typeorm` ที่ส่วนบนสุดของไฟล์ครับ + +แก้ไขไฟล์ `src/modules/user/entities/user.entity.ts` โดยเพิ่ม import ให้ครบถ้วนดังนี้ครับ: + +##### ไฟล์: `src/modules/user/entities/user.entity.ts` (ฉบับสมบูรณ์) + +```typescript +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + ManyToOne, // <--- เพิ่มตรงนี้ + JoinColumn, // <--- เพิ่มตรงนี้ +} from 'typeorm'; +import { Organization } from '../../project/entities/organization.entity.js'; // อย่าลืม import Organization + +@Entity('users') +export class User { + @PrimaryGeneratedColumn({ name: 'user_id' }) + user_id!: number; + + @Column({ unique: true, length: 50 }) + username!: string; + + @Column({ name: 'password_hash' }) + password!: string; + + @Column({ unique: true, length: 100 }) + email!: string; + + @Column({ name: 'first_name', nullable: true, length: 50 }) + firstName?: string; + + @Column({ name: 'last_name', nullable: true, length: 50 }) + lastName?: string; + + @Column({ name: 'is_active', default: true }) + isActive!: boolean; + + // Relation กับ Organization + @Column({ name: 'primary_organization_id', nullable: true }) + primaryOrganizationId?: number; + + @ManyToOne(() => Organization) + @JoinColumn({ name: 'primary_organization_id' }) + organization?: Organization; + + // Base Entity Fields (ที่เราแยกมาเขียนเองเพราะเรื่อง deleted_at) + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + @DeleteDateColumn({ name: 'deleted_at', select: false }) + deletedAt?: Date; +} +``` + +ลอง Save แล้วรันใหม่ครับ Error นี้จะหายไปแน่นอนครับ + +----- + +หากคอมไพล์ผ่านแล้วและไม่มี Error เพิ่มเติม เราถือว่า **จบ Phase 1** อย่างสมบูรณ์แล้วครับ + +#### src/modules/user/user.service.ts:55:9 - error TS2820: Type '"created_at"' is not assignable to type 'keyof User'. Did you mean '"createdAt"'? + +ปัญหานี้เกิดจาก **TypeScript** แจ้งว่าในไฟล์ `User` Entity เราตั้งชื่อตัวแปรเป็นแบบ **CamelCase** (`createdAt`) แต่ใน Service เราดันไปเรียกใช้ชื่อแบบ **Snake Case** (`created_at`) ซึ่งเป็นชื่อใน Database ครับ + +ใน TypeORM เวลาเราเขียน `select` เราต้องอ้างอิง **ชื่อ Property ใน Class** ไม่ใช่ชื่อ Column ใน Database ครับ + +##### วิธีแก้ไข + +ให้แก้ไขไฟล์ `src/modules/user/user.service.ts` ในฟังก์ชัน `findAll` ให้ใช้ชื่อตัวแปรให้ตรงกับ Entity ครับ + +**เปลี่ยนจาก:** `created_at`, `updated_at` +**เป็น:** `createdAt`, `updatedAt` + +```typescript +// src/modules/user/user.service.ts + + // ... + + // 2. ดึงข้อมูลทั้งหมด + async findAll(): Promise { + return this.usersRepository.find({ + // ✅ แก้ไขชื่อ field ให้ตรงกับ Entity Class + select: [ + 'user_id', + 'username', + 'email', + 'firstName', + 'lastName', + 'isActive', + 'createdAt', // แก้ตรงนี้ (จาก created_at) + 'updatedAt' // แก้ตรงนี้ (จาก updated_at) + ], + }); + } + + // ... +``` + +ลองแก้จุดนี้แล้ว Save ดูครับ Error จะหายไปครับ + +----- + +### 🚀 diff --git a/backend/src/modules/correspondence/correspondence.controller.ts b/backend/src/modules/correspondence/correspondence.controller.ts index fa8f6ab..b3b6ad9 100644 --- a/backend/src/modules/correspondence/correspondence.controller.ts +++ b/backend/src/modules/correspondence/correspondence.controller.ts @@ -5,9 +5,13 @@ import { Body, UseGuards, Request, + Param, // <--- ✅ 1. เพิ่ม Param + ParseIntPipe, // <--- ✅ 2. เพิ่ม ParseIntPipe } from '@nestjs/common'; import { CorrespondenceService } from './correspondence.service.js'; import { CreateCorrespondenceDto } from './dto/create-correspondence.dto.js'; +import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto.js'; // <--- ✅ 3. เพิ่ม Import DTO นี้ + import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js'; import { RbacGuard } from '../../common/auth/rbac.guard.js'; import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; @@ -28,4 +32,19 @@ export class CorrespondenceController { findAll() { return this.correspondenceService.findAll(); } + + // ✅ เพิ่ม Endpoint นี้ครับ + @Post(':id/submit') + @RequirePermission('correspondence.create') // หรือจะสร้าง Permission ใหม่ 'workflow.submit' ก็ได้ + submit( + @Param('id', ParseIntPipe) id: number, + @Body() submitDto: SubmitCorrespondenceDto, + @Request() req: any, + ) { + return this.correspondenceService.submit( + id, + submitDto.templateId, + req.user, + ); + } } diff --git a/backend/src/modules/correspondence/dto/submit-correspondence.dto.ts b/backend/src/modules/correspondence/dto/submit-correspondence.dto.ts new file mode 100644 index 0000000..634d384 --- /dev/null +++ b/backend/src/modules/correspondence/dto/submit-correspondence.dto.ts @@ -0,0 +1,7 @@ +import { IsInt, IsNotEmpty } from 'class-validator'; + +export class SubmitCorrespondenceDto { + @IsInt() + @IsNotEmpty() + templateId!: number; +} diff --git a/extensions_list.txt b/extensions_list.txt new file mode 100644 index 0000000..336071b Binary files /dev/null and b/extensions_list.txt differ diff --git a/test.sql b/test.sql new file mode 100644 index 0000000..c1042e5 --- /dev/null +++ b/test.sql @@ -0,0 +1,19 @@ +-- 1. สร้าง Template ชื่อ "General Approval" +INSERT INTO correspondence_routing_templates (id, template_name, description, is_active) +VALUES ( + 1, + 'General Approval', + 'Template สำหรับการอนุมัติทั่วไป', + 1 + ); +-- 2. สร้าง Steps (ส่งไป Org ID 1 ก่อน แล้วส่งไป Org ID 2) +-- (สมมติว่า Org ID 1 = Owner, Org ID 2 = Consultant ตาม Seed Data เดิม) +INSERT INTO correspondence_routing_template_steps ( + template_id, + sequence, + to_organization_id, + step_purpose, + expected_days + ) +VALUES (1, 1, 22, 'FOR_REVIEW', 3), + (1, 2, 1, 'FOR_APPROVAL', 5); \ No newline at end of file