251119:1700 Backend Phase 1
This commit is contained in:
16
backend/docker-compose.override.yml.example
Normal file
16
backend/docker-compose.override.yml.example
Normal file
@@ -0,0 +1,16 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Override ค่า Config ของ Service Backend (เมื่อเราสร้าง Container Backend ในอนาคต)
|
||||
backend:
|
||||
environment:
|
||||
# ใส่ Secrets จริงในไฟล์ override นี้ (และห้าม commit ไฟล์นี้)
|
||||
- DB_PASSWORD=Center2025
|
||||
- JWT_SECRET=eebc122aa65adde8c76c6a0847d9649b2b67a06db1504693e6c912e51499b76e
|
||||
- NODE_ENV=development
|
||||
|
||||
# Override สำหรับ Database (Local Dev)
|
||||
mariadb:
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=Center#2025
|
||||
- MYSQL_PASSWORD=Center2025
|
||||
@@ -1,9 +1,7 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.8
|
||||
container_name: lcbp3-db-local
|
||||
container_name: mariadb-local
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: Center#2025
|
||||
@@ -19,8 +17,8 @@ services:
|
||||
|
||||
# Optional: phpMyAdmin สำหรับจัดการ DB ง่ายๆ
|
||||
pma:
|
||||
image: phpmyadmin/phpmyadmin
|
||||
container_name: lcbp3-pma-local
|
||||
image: phpmyadmin
|
||||
container_name: pma-local
|
||||
environment:
|
||||
PMA_HOST: mariadb
|
||||
ports:
|
||||
@@ -30,8 +28,22 @@ services:
|
||||
networks:
|
||||
- lcbp3-net
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: lcbp3-redis-local
|
||||
restart: always
|
||||
# ใช้ Command นี้เพื่อตั้ง Password
|
||||
command: redis-server --requirepass "redis_password_secure"
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- lcbp3-net
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
redis_data: # เพิ่ม Volume
|
||||
|
||||
networks:
|
||||
lcbp3-net:
|
||||
|
||||
@@ -20,12 +20,24 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.1",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.63.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"joi": "^18.0.1",
|
||||
"mysql2": "^3.15.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.27"
|
||||
@@ -36,9 +48,11 @@
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
|
||||
640
backend/pnpm-lock.yaml
generated
640
backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,66 @@
|
||||
// src/app.module.ts
|
||||
// File: src/app.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq'; // Import BullModule
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { envValidationSchema } from './common/config/env.validation.js'; // สังเกต .js สำหรับ ESM
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 1. Load Config Module
|
||||
// 1. Setup Config Module พร้อม Validation
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // ให้เรียกใช้ได้ทุกที่โดยไม่ต้อง import ใหม่
|
||||
envFilePath: '.env', // อ่านค่าจากไฟล์ .env
|
||||
isGlobal: true, // เรียกใช้ได้ทั่วทั้ง App ไม่ต้อง import ซ้ำ
|
||||
envFilePath: '.env', // อ่านไฟล์ .env (สำหรับ Dev)
|
||||
validationSchema: envValidationSchema, // ใช้ Schema ที่เราสร้างเพื่อตรวจสอบ
|
||||
validationOptions: {
|
||||
// ถ้ามีค่าไหนไม่ผ่าน Validation ให้ Error และหยุดทำงานทันที
|
||||
abortEarly: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// 2. Setup TypeORM Connection (Async เพื่อรออ่าน Config ก่อน)
|
||||
// 2. Setup TypeORM (MariaDB)
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
type: 'mariadb', // หรือ 'mysql' ก็ได้เพราะใช้ driver เดียวกัน
|
||||
type: 'mariadb',
|
||||
host: configService.get<string>('DB_HOST'),
|
||||
port: configService.get<number>('DB_PORT'),
|
||||
username: configService.get<string>('DB_USERNAME'),
|
||||
password: configService.get<string>('DB_PASSWORD'),
|
||||
database: configService.get<string>('DB_DATABASE'),
|
||||
// Auto Load Entities: โหลด Entity ทั้งหมดที่อยู่ในโปรเจกต์อัตโนมัติ
|
||||
autoLoadEntities: true,
|
||||
// Synchronize: true เฉพาะ Dev environment (ห้ามใช้ใน Prod)
|
||||
synchronize: configService.get<string>('NODE_ENV') === 'development',
|
||||
// Logging: เปิดดู Query SQL ตอน Dev
|
||||
logging: configService.get<string>('NODE_ENV') === 'development',
|
||||
// synchronize: true เฉพาะตอน Dev เท่านั้น ห้ามใช้บน Prod
|
||||
// synchronize: configService.get<string>('NODE_ENV') === 'development',
|
||||
// แก้บรรทัดนี้เป็น false ครับ
|
||||
// เพราะเราใช้ SQL Script สร้าง DB แล้ว ไม่ต้องการให้ TypeORM มาแก้ Structure อัตโนมัติ
|
||||
synchronize: false,
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// 3. BullMQ (Redis) Setup [NEW]
|
||||
BullModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
connection: {
|
||||
host: configService.get<string>('REDIS_HOST'),
|
||||
port: configService.get<number>('REDIS_PORT'),
|
||||
password: configService.get<string>('REDIS_PASSWORD'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
CommonModule,
|
||||
|
||||
UserModule,
|
||||
|
||||
ProjectModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
18
backend/src/common/aut/aut.controller.spec.ts
Normal file
18
backend/src/common/aut/aut.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AutController } from './aut.controller';
|
||||
|
||||
describe('AutController', () => {
|
||||
let controller: AutController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AutController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AutController>(AutController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
4
backend/src/common/aut/aut.controller.ts
Normal file
4
backend/src/common/aut/aut.controller.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller('aut')
|
||||
export class AutController {}
|
||||
30
backend/src/common/auth/auth.controller.spec.ts
Normal file
30
backend/src/common/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
30
backend/src/common/auth/auth.controller.ts
Normal file
30
backend/src/common/auth/auth.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
31
backend/src/common/auth/auth.module.ts
Normal file
31
backend/src/common/auth/auth.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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 {}
|
||||
18
backend/src/common/auth/auth.service.spec.ts
Normal file
18
backend/src/common/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AuthService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
41
backend/src/common/auth/auth.service.ts
Normal file
41
backend/src/common/auth/auth.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
11
backend/src/common/auth/dto/login.dto.ts
Normal file
11
backend/src/common/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
30
backend/src/common/auth/dto/register.dto.ts
Normal file
30
backend/src/common/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
}
|
||||
5
backend/src/common/auth/jwt-auth.guard.ts
Normal file
5
backend/src/common/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
26
backend/src/common/auth/jwt.strategy.ts
Normal file
26
backend/src/common/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
55
backend/src/common/auth/rbac.guard.ts
Normal file
55
backend/src/common/auth/rbac.guard.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
9
backend/src/common/common.module.ts
Normal file
9
backend/src/common/common.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AutController } from './aut/aut.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [AutController]
|
||||
})
|
||||
export class CommonModule {}
|
||||
31
backend/src/common/config/env.validation.ts
Normal file
31
backend/src/common/config/env.validation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// File: src/common/config/env.validation.ts
|
||||
import Joi from 'joi';
|
||||
|
||||
// สร้าง Schema สำหรับตรวจสอบค่า Environment Variables
|
||||
export const envValidationSchema = Joi.object({
|
||||
// 1. Application Environment
|
||||
NODE_ENV: Joi.string()
|
||||
.valid('development', 'production', 'test', 'provision')
|
||||
.default('development'),
|
||||
PORT: Joi.number().default(3000),
|
||||
|
||||
// 2. Database Configuration (MariaDB)
|
||||
// ห้ามเป็นค่าว่าง (required)
|
||||
DB_HOST: Joi.string().required(),
|
||||
DB_PORT: Joi.number().default(3306),
|
||||
DB_USERNAME: Joi.string().required(),
|
||||
DB_PASSWORD: Joi.string().required(),
|
||||
DB_DATABASE: Joi.string().required(),
|
||||
|
||||
// 3. Security (JWT)
|
||||
// ต้องมีค่า และควรยาวพอ (ตรวจสอบความยาวได้ถ้าระบุ min)
|
||||
JWT_SECRET: Joi.string()
|
||||
.required()
|
||||
.min(32)
|
||||
.message('JWT_SECRET must be at least 32 characters long for security.'),
|
||||
JWT_EXPIRATION: Joi.string().default('8h'),
|
||||
// 4. Redis Configuration (เพิ่มส่วนนี้)
|
||||
REDIS_HOST: Joi.string().required(),
|
||||
REDIS_PORT: Joi.number().default(6379),
|
||||
REDIS_PASSWORD: Joi.string().required(),
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSION_KEY = 'permissions';
|
||||
|
||||
// ใช้สำหรับแปะหน้า Controller/Method
|
||||
// ตัวอย่าง: @RequirePermission('user.create')
|
||||
export const RequirePermission = (permission: string) =>
|
||||
SetMetadata(PERMISSION_KEY, permission);
|
||||
20
backend/src/common/entities/base.entity.ts
Normal file
20
backend/src/common/entities/base.entity.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
}
|
||||
50
backend/src/common/exceptions/http-exception.filter.ts
Normal file
50
backend/src/common/exceptions/http-exception.filter.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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
|
||||
const message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as any).message || exceptionResponse;
|
||||
// 👇👇 เพิ่มบรรทัดนี้ครับ (สำคัญมาก!) 👇👇
|
||||
console.error('💥 REAL ERROR:', exception);
|
||||
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
||||
32
backend/src/common/interceptors/transform.interceptor.ts
Normal file
32
backend/src/common/interceptors/transform.interceptor.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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 มา
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,31 @@
|
||||
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);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
41
backend/src/modules/project/entities/contract.entity.ts
Normal file
41
backend/src/modules/project/entities/contract.entity.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
}
|
||||
17
backend/src/modules/project/entities/organization.entity.ts
Normal file
17
backend/src/modules/project/entities/organization.entity.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
}
|
||||
17
backend/src/modules/project/entities/project.entity.ts
Normal file
17
backend/src/modules/project/entities/project.entity.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
}
|
||||
19
backend/src/modules/project/project.controller.spec.ts
Normal file
19
backend/src/modules/project/project.controller.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
4
backend/src/modules/project/project.controller.ts
Normal file
4
backend/src/modules/project/project.controller.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller('project')
|
||||
export class ProjectController {}
|
||||
25
backend/src/modules/project/project.module.ts
Normal file
25
backend/src/modules/project/project.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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 {}
|
||||
18
backend/src/modules/project/project.service.spec.ts
Normal file
18
backend/src/modules/project/project.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ProjectService } from './project.service';
|
||||
|
||||
describe('ProjectService', () => {
|
||||
let service: ProjectService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ProjectService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ProjectService>(ProjectService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
25
backend/src/modules/project/project.service.ts
Normal file
25
backend/src/modules/project/project.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
44
backend/src/modules/user/dto/create-user.dto.ts
Normal file
44
backend/src/modules/user/dto/create-user.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
}
|
||||
4
backend/src/modules/user/dto/update-user.dto.ts
Normal file
4
backend/src/modules/user/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateUserDto } from './create-user.dto.js';
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||
53
backend/src/modules/user/entities/user.entity.ts
Normal file
53
backend/src/modules/user/entities/user.entity.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
}
|
||||
57
backend/src/modules/user/user.controller.ts
Normal file
57
backend/src/modules/user/user.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
|
||||
import { RbacGuard } from '../../common/auth/rbac.guard.js';
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard) // 🔒 เพิ่ม RbacGuard ต่อท้าย) // 🔒 บังคับ Login ทุก Endpoints ในนี้
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
// 1. สร้างผู้ใช้ใหม่
|
||||
@Post()
|
||||
@RequirePermission('user.create') // 🔒 ต้องมีสิทธิ์ user.create ถึงจะเข้าได้
|
||||
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);
|
||||
}
|
||||
}
|
||||
14
backend/src/modules/user/user.module.ts
Normal file
14
backend/src/modules/user/user.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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])], // จดทะเบียน Entity
|
||||
// 2. เพิ่มบรรทัดนี้ เพื่อบอก NestJS ว่ามี Controller นี้อยู่
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService], // Export ให้ AuthModule เรียกใช้ได้
|
||||
})
|
||||
export class UserModule {}
|
||||
18
backend/src/modules/user/user.service.spec.ts
Normal file
18
backend/src/modules/user/user.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UserService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserService>(UserService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
119
backend/src/modules/user/user.service.ts
Normal file
119
backend/src/modules/user/user.service.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
|
||||
// 👇👇 เพิ่มฟังก์ชันใหม่นี้ 👇👇
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
Reference in New Issue
Block a user