2404 lines
98 KiB
Markdown
2404 lines
98 KiB
Markdown
# **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<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`
|
|
|
|
```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<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`
|
|
|
|
```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<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
|
|
|
|
รันคำสั่ง:
|
|
|
|
```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<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`:
|
|
|
|
```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<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`:
|
|
|
|
```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<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`:
|
|
|
|
```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>` ส่งคืนค่าเป็น `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<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` แบบนี้ก็ได้ครับ (แนะนำแบบนี้มากกว่า):
|
|
|
|
```typescript
|
|
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`**
|
|
|
|
```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<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`
|
|
|
|
ใส่ `!` หลังชื่อตัวแปรทุกตัวครับ
|
|
|
|
```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<string>('JWT_SECRET')!,
|
|
});
|
|
}
|
|
|
|
async validate(payload: any) {
|
|
return { userId: payload.sub, username: payload.username };
|
|
}
|
|
}
|
|
```
|
|
|
|
**ทางเลือกอื่น (ถ้าใช้ NestJS เวอร์ชั่นใหม่):**
|
|
คุณสามารถใช้ `getOrThrow` แทนได้ครับ ซึ่งจะช่วยแก้ Type Error และเช็ค Runtime ไปในตัว:
|
|
|
|
```typescript
|
|
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 ครบถ้วน*
|
|
|
|
```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<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*
|
|
|
|
```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<string>('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<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` ครับ
|
|
|
|
```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` กลับมาครับ
|
|
<!-- end list -->
|
|
```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<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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
// 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` ครับ
|
|
|
|
```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 นะครับ)
|
|
|
|
คราวนี้น่าจะเจอข้อมูลแล้วครับ\! 🎉
|
|
|
|
#### ปัญหานี้เกิดจากเราได้สร้าง `UserAssignmentService` และเรียกใช้ใน Controller แล้ว แต่เรา **ลืมลงทะเบียน (Register)** Service นี้และ Entity ของมันใน `UserModule` ครับ NestJS เลยไม่รู้ว่าจะเอา Service นี้มาจากไหน
|
|
|
|
##### 🛠️ วิธีแก้ไข
|
|
|
|
ให้แก้ไขไฟล์ `src/modules/user/user.module.ts` โดยเพิ่ม `UserAssignment` (Entity) และ `UserAssignmentService` (Provider) เข้าไปครับ
|
|
|
|
**ไฟล์: `src/modules/user/user.module.ts`**
|
|
|
|
```typescript
|
|
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 เรื่องสิทธิ์ที่ซับซ้อนครับ
|
|
|
|
รันคำสั่ง:
|
|
|
|
```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<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`:
|
|
|
|
```typescript
|
|
// ... (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`:
|
|
|
|
```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<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`:
|
|
|
|
```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<User[]> {
|
|
return this.usersRepository.find({
|
|
// ✅ แก้ไขชื่อ field ให้ตรงกับ Entity Class
|
|
select: [
|
|
'user_id',
|
|
'username',
|
|
'email',
|
|
'firstName',
|
|
'lastName',
|
|
'isActive',
|
|
'createdAt', // แก้ตรงนี้ (จาก created_at)
|
|
'updatedAt' // แก้ตรงนี้ (จาก updated_at)
|
|
],
|
|
});
|
|
}
|
|
|
|
// ...
|
|
```
|
|
|
|
ลองแก้จุดนี้แล้ว Save ดูครับ Error จะหายไปครับ
|
|
|
|
-----
|
|
|
|
### 🚀
|