251119:1700 Backend Phase 1

This commit is contained in:
admin
2025-11-19 16:58:44 +07:00
parent 2b36e5554b
commit 4c961aa4ee
42 changed files with 1701 additions and 45 deletions

View 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

View File

@@ -1,9 +1,7 @@
version: '3.8'
services: services:
mariadb: mariadb:
image: mariadb:11.8 image: mariadb:11.8
container_name: lcbp3-db-local container_name: mariadb-local
restart: always restart: always
environment: environment:
MYSQL_ROOT_PASSWORD: Center#2025 MYSQL_ROOT_PASSWORD: Center#2025
@@ -19,8 +17,8 @@ services:
# Optional: phpMyAdmin สำหรับจัดการ DB ง่ายๆ # Optional: phpMyAdmin สำหรับจัดการ DB ง่ายๆ
pma: pma:
image: phpmyadmin/phpmyadmin image: phpmyadmin
container_name: lcbp3-pma-local container_name: pma-local
environment: environment:
PMA_HOST: mariadb PMA_HOST: mariadb
ports: ports:
@@ -30,8 +28,22 @@ services:
networks: networks:
- lcbp3-net - 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: volumes:
db_data: db_data:
redis_data: # เพิ่ม Volume
networks: networks:
lcbp3-net: lcbp3-net:

View File

@@ -20,12 +20,24 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.3",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@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/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0", "@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", "mysql2": "^3.15.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.27" "typeorm": "^0.3.27"
@@ -36,9 +48,11 @@
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",

640
backend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,66 @@
// src/app.module.ts // File: src/app.module.ts
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq'; // Import BullModule
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; 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({ @Module({
imports: [ imports: [
// 1. Load Config Module // 1. Setup Config Module พร้อม Validation
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, // ให้เรียกใช้ได้ทุกที่โดยไม่ต้อง import ใหม่ isGlobal: true, // เรียกใช้ได้ทั่วทั้ง App ไม่ต้อง import ซ้ำ
envFilePath: '.env', // อ่านค่าจากไฟล์ .env envFilePath: '.env', // อ่านไฟล์ .env (สำหรับ Dev)
validationSchema: envValidationSchema, // ใช้ Schema ที่เราสร้างเพื่อตรวจสอบ
validationOptions: {
// ถ้ามีค่าไหนไม่ผ่าน Validation ให้ Error และหยุดทำงานทันที
abortEarly: true,
},
}), }),
// 2. Setup TypeORM Connection (Async เพื่อรออ่าน Config ก่อน) // 2. Setup TypeORM (MariaDB)
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({ useFactory: async (configService: ConfigService) => ({
type: 'mariadb', // หรือ 'mysql' ก็ได้เพราะใช้ driver เดียวกัน type: 'mariadb',
host: configService.get<string>('DB_HOST'), host: configService.get<string>('DB_HOST'),
port: configService.get<number>('DB_PORT'), port: configService.get<number>('DB_PORT'),
username: configService.get<string>('DB_USERNAME'), username: configService.get<string>('DB_USERNAME'),
password: configService.get<string>('DB_PASSWORD'), password: configService.get<string>('DB_PASSWORD'),
database: configService.get<string>('DB_DATABASE'), database: configService.get<string>('DB_DATABASE'),
// Auto Load Entities: โหลด Entity ทั้งหมดที่อยู่ในโปรเจกต์อัตโนมัติ
autoLoadEntities: true, autoLoadEntities: true,
// Synchronize: true เฉพาะ Dev environment (ห้ามใช้น Prod) // synchronize: true เฉพาะตอน Dev เท่านั้น ห้ามใช้น Prod
synchronize: configService.get<string>('NODE_ENV') === 'development', // synchronize: configService.get<string>('NODE_ENV') === 'development',
// Logging: เปิดดู Query SQL ตอน Dev // แก้บรรทัดนี้เป็น false ครับ
logging: configService.get<string>('NODE_ENV') === 'development', // เพราะเราใช้ SQL Script สร้าง DB แล้ว ไม่ต้องการให้ TypeORM มาแก้ Structure อัตโนมัติ
synchronize: false,
}), }),
}),
// 3. BullMQ (Redis) Setup [NEW]
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService], 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], controllers: [AppController],
providers: [AppService], providers: [AppService],

View 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();
});
});

View File

@@ -0,0 +1,4 @@
import { Controller } from '@nestjs/common';
@Controller('aut')
export class AutController {}

View 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);
}
}

View 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);
}
}

View 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 {}

View 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();
});
});

View 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,
});
}
}

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsString()
@IsNotEmpty()
username!: string;
@IsString()
@IsNotEmpty()
password!: string;
}

View 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;
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View 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 };
}
}

View 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;
}
}

View 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 {}

View 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(),
});

View File

@@ -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);

View 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;
}

View 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
});
}
}

View 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 มา
})),
);
}
}

View File

@@ -1,8 +1,31 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; 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() { async function bootstrap() {
const app = await NestFactory.create(AppModule); 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(); bootstrap();

View File

@@ -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;
}

View 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;
}

View 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;
}

View File

@@ -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;
}

View 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;
}

View 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();
}
}

View File

@@ -0,0 +1,4 @@
import { Controller } from '@nestjs/common';
@Controller('project')
export class ProjectController {}

View 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 {}

View 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();
});
});

View 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();
}
}

View 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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto.js';
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View 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;
}

View 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);
}
}

View 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 {}

View 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();
});
});

View 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);
}
}

View File

@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "NodeNext",
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"resolvePackageJsonExports": true, "resolvePackageJsonExports": true,
"esModuleInterop": true, "esModuleInterop": true,