Files
lcbp3/infrastructure/Project/T1-0 Setting Project.md
2025-11-30 13:58:46 +07:00

98 KiB

BACKEND DEVELOPMENT

T1.1 CommonModule

ตอนนี้เราจะเข้าสู่ Phase 1: Core Foundation เริ่มจาก Task T1.1 CommonModule - Base Infrastructure ครับ

เป้าหมายคือสร้าง "รากฐาน" ที่ทุก Module ต้องใช้ร่วมกัน เพื่อให้โค้ดเป็นระเบียบและลดความซ้ำซ้อนครับ

1. สร้าง CommonModule

เพื่อให้ NestJS รู้จักไฟล์ต่างๆ ในโฟลเดอร์ common เราควรสร้าง Module หลักมาห่อหุ้มไว้ครับ

รันคำสั่ง:

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

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

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  statusCode: number;
  message: string;
  data: T;
}

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

สำหรับจัดการ Error ทั้งหมดในระบบ ให้ตอบกลับเป็น JSON ที่สวยงามและ ไม่เปิดเผยข้อมูล Sensitive (เช่น Stack Trace) ตามข้อกำหนด Security ครับ

สร้างไฟล์: src/common/exceptions/http-exception.filter.ts

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<Response>();
    const request = ctx.getRequest<Request>();

    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

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)

รันคำสั่ง:

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)

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:

รันคำสั่ง:

nest g module modules/user
nest g service modules/user

แก้ไข src/modules/user/user.module.ts:

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:

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<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);
    return this.usersRepository.save(user);
  }
}

3. สร้าง Auth Module และ Service

รันคำสั่ง:

nest g module common/auth
nest g service common/auth
nest g controller common/auth

แก้ไข src/common/auth/auth.module.ts เพื่อตั้งค่า JWT:

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<string>('JWT_SECRET'),
        signOptions: { 
            expiresIn: configService.get<string>('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:

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<any> {
    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:

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<string>('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:

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 เพื่อตรวจสอบข้อมูลขาเข้าด้วยครับ

pnpm add class-validator class-transformer

2. สร้าง DTO ไฟล์

สร้างโฟลเดอร์ dto ใน src/common/auth/ และสร้างไฟล์ DTO 2 ไฟล์ครับ:

ไฟล์: src/common/auth/dto/login.dto.ts

import { IsNotEmpty, IsString } from 'class-validator';

export class LoginDto {
  @IsString()
  @IsNotEmpty()
  username: string;

  @IsString()
  @IsNotEmpty()
  password: string;
}

ไฟล์: src/common/auth/dto/register.dto.ts

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

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)

// ... 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> ส่งคืนค่าเป็น string ทั่วไป ทำให้ TypeScript มองว่า Type ไม่ตรงกัน 100%

วิธีแก้ไขคือเราต้อง Cast Type ของค่า expiresIn ให้ TypeScript ยอมรับครับ

แก้ไขไฟล์ src/common/auth/auth.module.ts ดังนี้ครับ:

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<string>('JWT_SECRET'),
        signOptions: {
          // แก้ไขโดยการ Cast as any หรือ as string | number เพื่อให้ TypeScript ยอมรับ
          expiresIn: configService.get<string>('JWT_EXPIRATION') || '8h',
        } as any, // <--- เพิ่ม as any ตรงนี้ หรือจะใส่ที่บรรทัด expiresIn ก็ได้
      }),
    }),
  ],
  providers: [AuthService, JwtStrategy],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

หรือ จะแก้เฉพาะบรรทัด expiresIn แบบนี้ก็ได้ครับ (แนะนำแบบนี้มากกว่า):

      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: {
          // Cast เฉพาะค่า Value
          expiresIn: (configService.get<string>('JWT_EXPIRATION') || '8h') as any, 
        },
      }),

ลองแก้แล้ว Save ดูครับ Error ควรจะหายไปครับ

ปัญหานี้เกิดจากยังไม่ได้ Import RegisterDto เข้ามาในไฟล์ src/common/auth/auth.service.ts ครับ

ให้เพิ่มบรรทัด import ที่ส่วนบนสุดของไฟล์ครับ:

ไฟล์: src/common/auth/auth.service.ts

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<any> {
    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

ใส่ ! หลังชื่อตัวแปรทุกตัวครับ

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 ไม่ต้องใส่ ! ครับ)

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 ดังนี้ครับ:

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<string>('JWT_SECRET')!, 
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

ทางเลือกอื่น (ถ้าใช้ NestJS เวอร์ชั่นใหม่): คุณสามารถใช้ getOrThrow แทนได้ครับ ซึ่งจะช่วยแก้ Type Error และเช็ค Runtime ไปในตัว:

secretOrKey: configService.getOrThrow<string>('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 ครบถ้วน

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<any> {
    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

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<string>('JWT_SECRET')!, 
    });
  }

  async validate(payload: JwtPayload) {
    return { userId: payload.sub, username: payload.username };
  }
}
3. ไฟล์ src/common/auth/auth.module.ts (ฉบับแก้ครบ)

Cast Type ตรง expiresIn

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<string>('JWT_SECRET'),
        signOptions: {
          // Cast เป็น any เพื่อแก้ปัญหา Type ไม่ตรงกับ Library
          expiresIn: (configService.get<string>('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 ครับ

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

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

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 ออกมาครับ

// 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 แล้วหรือยังด้วยคำสั่ง:

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. ใส่ข้อมูลในช่องว่าง:

    {
      "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. ใส่ข้อมูล:

    {
      "username": "admin",
      "password": "password123"
    }
    
  7. กดปุ่ม Send

    • ผลลัพธ์: ควรได้ access_token กลับมาครับ
    {
      "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:

pnpm add @nestjs/mapped-types

2. สร้างไฟล์ create-user.dto.ts

ไฟล์นี้จะกำหนดว่า "ตอนสร้าง User ใหม่" ต้องส่งค่าอะไรมาบ้าง และมีกฎอย่างไร

สร้างไฟล์: src/modules/user/dto/create-user.dto.ts

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

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

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

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 ต้องการแล้วครับ)

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<User>,
  ) {}

  // 1. สร้างผู้ใช้ (Hash Password ก่อนบันทึก)
  async create(createUserDto: CreateUserDto): Promise<User> {
    // สร้าง 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<User[]> {
    return this.usersRepository.find({
      // ไม่ส่ง password กลับไปเพื่อความปลอดภัย
      select: ['user_id', 'username', 'email', 'firstName', 'lastName', 'isActive', 'created_at', 'updated_at'],
    });
  }

  // 3. ดึงข้อมูลรายคน
  async findOne(id: number): Promise<User> {
    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<User | null> {
    return this.usersRepository.findOne({ where: { username } });
  }

  // 4. แก้ไขข้อมูล
  async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
    // เช็คก่อนว่ามี 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<void> {
    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) กันต่อไหมครับ?

"มอบหมายบทบาท (Assign Role)"

ถูกต้องที่สุดครับ! คุณเข้าใจคอนเซปต์ของระบบ RBAC ในระบบนี้ได้อย่างทะลุปรุโปร่งครับ 👍

ถ้าเราสร้าง User ลงในตาราง users เพียงอย่างเดียว User คนนั้นจะ:

  1. Login ได้ (เพราะมี Username/Password)
  2. ทำอะไรไม่ได้เลย (เพราะไม่มี Row ใน user_assignments ระบบสิทธิ์ v_user_all_permissions จึงว่างเปล่า)

ดังนั้น การ "มอบหมายบทบาท (Assign Role)" จึงเป็นขั้นตอนที่ขาดไม่ได้ครับ


🛠️ สิ่งที่เราต้องทำเพิ่ม (เพื่อให้ Admin มอบสิทธิ์ได้)

ใน Task T1.3 เราทำแค่ Create User (ลงตาราง users) ไปแล้ว ตอนนี้เราต้องทำ API สำหรับ Assign Role (ลงตาราง user_assignments) ครับ

มาสร้างฟังก์ชันนี้กันครับ:

1. สร้าง DTO สำหรับการมอบสิทธิ์

สร้างไฟล์: src/modules/user/dto/assign-role.dto.ts

import { IsInt, IsNotEmpty, IsOptional, ValidateIf } from 'class-validator';

export class AssignRoleDto {
  @IsInt()
  @IsNotEmpty()
  userId!: number;

  @IsInt()
  @IsNotEmpty()
  roleId!: number;

  // Scope (ต้องส่งมาอย่างน้อย 1 อัน หรือไม่ส่งเลยถ้าเป็น Global)
  @IsInt()
  @IsOptional()
  organizationId?: number;

  @IsInt()
  @IsOptional()
  projectId?: number;

  @IsInt()
  @IsOptional()
  contractId?: number;
}
2. สร้าง UserAssignmentService (Logic การบันทึก)

เราควรแยก Service นี้ออกมาเพื่อความเป็นระเบียบครับ (หรือจะใส่ใน UserService ก็ได้ แต่แยกดีกว่า)

สร้างไฟล์: src/modules/user/user-assignment.service.ts

import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserAssignment } from './entities/user-assignment.entity.js'; // ต้องไปสร้าง Entity นี้ก่อน (ดูข้อ 3)
import { AssignRoleDto } from './dto/assign-role.dto.js';
import { User } from './entities/user.entity.js';

@Injectable()
export class UserAssignmentService {
  constructor(
    @InjectRepository(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);
    if (scopes.length > 1) {
      throw new BadRequestException('Cannot assign multiple scopes at once. Choose one of Org, Project, or Contract.');
    }

    // สร้าง Assignment
    const assignment = this.assignmentRepo.create({
      userId: dto.userId,
      roleId: dto.roleId,
      organizationId: dto.organizationId,
      projectId: dto.projectId,
      contractId: dto.contractId,
      assignedByUserId: assigner.user_id, // เก็บ Log ว่าใครเป็นคนให้สิทธิ์
    });

    return this.assignmentRepo.save(assignment);
  }
}
3. สร้าง Entity UserAssignment (ถ้ายังไม่ได้สร้าง)

สร้างไฟล์: src/modules/user/entities/user-assignment.entity.ts

import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, CreateDateColumn } from 'typeorm';
import { User } from './user.entity.js';
// Import Role, Org, Project, Contract entities...

@Entity('user_assignments')
export class UserAssignment {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ name: 'user_id' })
  userId!: number;

  @Column({ name: 'role_id' })
  roleId!: number;

  @Column({ name: 'organization_id', nullable: true })
  organizationId?: number;

  @Column({ name: 'project_id', nullable: true })
  projectId?: number;

  @Column({ name: 'contract_id', nullable: true })
  contractId?: number;

  @Column({ name: 'assigned_by_user_id', nullable: true })
  assignedByUserId?: number;

  @CreateDateColumn({ name: 'assigned_at' })
  assignedAt!: Date;

  // Relation กลับไปหา User (เจ้าของสิทธิ์)
  @ManyToOne(() => User)
  @JoinColumn({ name: 'user_id' })
  user?: User;
}
4. เพิ่ม Endpoint ใน UserController
// src/modules/user/user.controller.ts
// ... imports ...
import { AssignRoleDto } from './dto/assign-role.dto.js';
import { UserAssignmentService } from './user-assignment.service.js'; // อย่าลืม Inject เข้ามาใน Constructor

@Controller('users')
@UseGuards(JwtAuthGuard, RbacGuard)
export class UserController {
  constructor(
    private readonly userService: UserService,
    private readonly assignmentService: UserAssignmentService, // <--- Inject เพิ่ม
  ) {}

  // ... Endpoints เดิม ...

  @Post('assign-role')
  @RequirePermission('permission.assign') // เฉพาะคนมีสิทธิ์ Assign (เช่น Admin)
  assignRole(@Body() dto: AssignRoleDto, @Request() req: any) {
    return this.assignmentService.assignRole(dto, req.user);
  }
}

📝 สรุปภาพรวม

  1. สร้าง User: POST /users -> ได้ User เปล่าๆ (Login ได้ แต่ทำไรไม่ได้)
  2. กำหนดสิทธิ์: POST /users/assign-role -> ยิง API นี้เพื่อบอกว่า "User นี้ เป็น Project Manager ของ Project A นะ"
    • 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 ตัวนี้แหละครับ)

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 ครับ

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 มันจะใส่ให้อัตโนมัติ แต่เช็คเพื่อความชัวร์ครับ)

// 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 นะครับ)

คราวนี้น่าจะเจอข้อมูลแล้วครับ! 🎉

ปัญหานี้เกิดจากเราได้สร้าง UserAssignmentService และเรียกใช้ใน Controller แล้ว แต่เรา ลืมลงทะเบียน (Register) Service นี้และ Entity ของมันใน UserModule ครับ NestJS เลยไม่รู้ว่าจะเอา Service นี้มาจากไหน

🛠️ วิธีแก้ไข

ให้แก้ไขไฟล์ src/modules/user/user.module.ts โดยเพิ่ม UserAssignment (Entity) และ UserAssignmentService (Provider) เข้าไปครับ

ไฟล์: src/modules/user/user.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service.js';
import { UserAssignmentService } from './user-assignment.service.js'; // <--- 1. Import Service
import { UserController } from './user.controller.js';
import { User } from './entities/user.entity.js';
import { UserAssignment } from './entities/user-assignment.entity.js'; // <--- 2. Import Entity

@Module({
  imports: [
    // 3. ลงทะเบียน Entity ทั้ง User และ UserAssignment
    TypeOrmModule.forFeature([User, UserAssignment]), 
  ],
  controllers: [UserController],
  providers: [
    UserService, 
    UserAssignmentService // <--- 4. ลงทะเบียน Service เป็น Provider
  ],
  exports: [
    UserService, 
    UserAssignmentService // <--- 5. Export เผื่อที่อื่นใช้
  ],
})
export class UserModule {}

ลอง Save แล้วรัน pnpm start:dev อีกครั้งครับ Error นี้จะหายไป และคุณจะสามารถใช้ฟังก์ชัน Assign Role ได้แล้วครับ!

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 เรื่องสิทธิ์ที่ซับซ้อนครับ

รันคำสั่ง:

pnpm add @casl/ability

ขั้นตอนที่ 2: สร้าง Decorator @RequirePermission()

เราจะสร้างป้ายชื่อ (Decorator) เพื่อแปะไว้หน้า Controller ว่า "ใครจะเข้าห้องนี้ ต้องมีบัตรผ่านนี้นะ"

สร้างไฟล์: src/common/decorators/require-permission.decorator.ts

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

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<boolean> {
    // 1. ดูว่า Controller นี้ต้องการสิทธิ์อะไร?
    const requiredPermission = this.reflector.getAllAndOverride<string>(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:

// ... (imports เดิม)

@Injectable()
export class UserService {
  // ... (methods เดิม)

  // 👇👇 เพิ่มฟังก์ชันใหม่นี้ 👇👇
  async getUserPermissions(userId: number): Promise<string[]> {
    // 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:

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

รันคำสั่ง:

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

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

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

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 ได้แล้วครับ

// 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 นะครับ

@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

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

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:

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:

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<Project>,
    @InjectRepository(Organization)
    private organizationRepository: Repository<Organization>,
  ) {}

  // ดึงรายการ Project ทั้งหมด
  async findAllProjects() {
    return this.projectRepository.find();
  }

  // ดึงรายการ Organization ทั้งหมด
  async findAllOrganizations() {
    return this.organizationRepository.find();
  }
}

และแก้ไข src/modules/project/project.controller.ts:

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 (ฉบับสมบูรณ์)
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

// src/modules/user/user.service.ts

  // ...

  // 2. ดึงข้อมูลทั้งหมด
  async findAll(): Promise<User[]> {
    return this.usersRepository.find({
      // ✅ แก้ไขชื่อ field ให้ตรงกับ Entity Class
      select: [
        'user_id', 
        'username', 
        'email', 
        'firstName', 
        'lastName', 
        'isActive', 
        'createdAt', // แก้ตรงนี้ (จาก created_at)
        'updatedAt'  // แก้ตรงนี้ (จาก updated_at)
      ],
    });
  }
  
  // ...

ลองแก้จุดนี้แล้ว Save ดูครับ Error จะหายไปครับ


🚀