5.3 · REST API surface + DTOs
บทนี้รวบรวม endpoint ทั้ง 17 ตัวของ centralize-api พร้อม DTO classes ที่ใช้ตรวจสอบ input จุดสำคัญคือ ทุก endpoint เป็น read-only (GET 15 ตัว = App 1 + Employee 14; POST 2 ตัวที่เป็น read-with-body) ไม่มี mutation endpoint ใด ๆ
ภาพรวม endpoint
ทั้งหมด 17 endpoints ใน 4 controllers อยู่ใต้ prefix /api
| Controller | Base path | จำนวน endpoint | ไฟล์ |
|---|---|---|---|
AppController | / | 1 GET | src/app.controller.ts |
EmployeeController | /employee | 14 GET | src/modules/employee/employee.controller.ts |
HrPaySlipController | /hrPaySlip | 1 POST | src/modules/hrPaySlip/hrpayslip.controller.ts |
HrTimeTempController | /hrTimeTemp | 1 POST | src/modules/hrTimeTemp/hrtimetemp.controller.ts |
HTTP verb distribution
| Verb | จำนวน | ลักษณะ |
|---|---|---|
| GET | 15 | read-only (App 1 + Employee 14) |
| POST | 2 | read-with-body (array param ใหญ่เกิน query string) |
| PUT | 0 | — |
| PATCH | 0 | — |
| DELETE | 0 | — |
ยืนยันว่า centralize-api เป็น pure read facade — ไม่มี INSERT/UPDATE/DELETE SQL ใด ๆ ใน codebase (ตรวจสอบโดยค้นหาทั้งระบบ)
AppController — endpoint เดียวที่ชื่อเข้าใจผิด
@ApiTags('Root')
@Controller()
export class AppController {
constructor(
private readonly appService: AppService,
private configService: ConfigService,
) {}
logger = new I3GatewayLogger(AppController.name);
@Public()
@Get()
getHello(@Req() req) {
return this.appService.getHello();
}
}| # | Verb | Path | Public | สิ่งที่ return จริง |
|---|---|---|---|---|
| 1 | GET | /api | Yes | org/division/department hierarchy 5-level (130-line CTE) |
ชื่อ method getHello ทำให้เข้าใจผิดว่าเป็น health check แต่ AppService.getHello() (app.service.ts:13-142) รัน raw SQL ขนาดใหญ่ที่ union ข้อมูลจาก dbHRMI_Center + dbHRMI_Center_NBC และ join 6 tables เพื่อสร้าง org hierarchy
ผลข้างเคียง — ถ้าใครเรียก /api เพื่อทดสอบว่า service รันอยู่จะกระตุ้น query หนัก ทำให้ไม่เหมาะเป็น health probe (บท 5.2)
EmployeeController — core 14 GET endpoints
@ApiBearerAuth()
@ApiTags('Employee')
@Controller('employee')
export class EmployeeController {
constructor(
private service: EmployeeService,
private technicalPositionService: TechnicalPositionService,
private employeeGroupService: EmployeeGroupService,
private employeeLevelService: EmployeeLevelService,
) {}
@Public() @Get() getEmployees(query) // 1
@Public() @Get('cu') getEmployeesCU(query) // 2
@Public() @Get('manager') getManagers(query) // 3
@Public() @Get('organize') getOrganizes() // 4
@Public() @Get('level') getLevels(query) // 5
@Public() @Get('division') getDivisions(query) // 6
@Public() @Get('department') getDepartments(query) // 7
@Public() @Get('posManage') getPositionManagers(query) // 8
@Public() @Get('position') getPositions(query) // 9
@Public() @Get('posTechnical') getTechnicalPositions(query) // 10
@Public() @Get('group') getEmployeeGroups(query) // 11
@Public() @Get('getByEmpCode') getEmployeeByEmployeeCode(query) // 12
@Public() @Get('getEmployeeDisaster') getEmployeeDisaster(query) // 13
@Public() @Get('bankAccount') getBankAccount(query) // 14
}ตาราง endpoint เต็ม
| # | Verb | Path | DTO | Source data | หน้าที่ |
|---|---|---|---|---|---|
| 1 | GET | /api/employee | GetEmployeeQueryDto | vtrcMainProfile view | list employees (working status filter, multi-tenant) |
| 2 | GET | /api/employee/cu | GetEmployeeQueryDto | vtrcAllProfile view | list employees CU scope |
| 3 | GET | /api/employee/manager | GetManagersQueryDto | org CTE | managers by dept/org/divi code |
| 4 | GET | /api/employee/organize | (none) | org CTE | distinct org codes (top of hierarchy) |
| 5 | GET | /api/employee/level | GetEmployeeLevelsQueryDto | emEmplLevel_reStructure | employee levels (delegate EmployeeLevelService) |
| 6 | GET | /api/employee/division | GetDivisionQueryDto | org CTE | divisions by orgCode + sourceDB |
| 7 | GET | /api/employee/department | GetDepartmentQueryDto | org CTE | departments by diviCode + sourceDB |
| 8 | GET | /api/employee/posManage | GetPositionManagersQueryDto | emPositionManage_reStructure | position managers (UNION center + NBC) |
| 9 | GET | /api/employee/position | GetPositionsQueryDto | emPosition_reStructure | positions (UNION center + NBC) |
| 10 | GET | /api/employee/posTechnical | GetTechnicalPositionsQueryDto | emPositionTechnical_reStructure | technical positions (delegate TechnicalPositionService) |
| 11 | GET | /api/employee/group | GetEmployeeGroupsQueryDto | emEmplGrup_reStructure | employee groups (delegate EmployeeGroupService) |
| 12 | GET | /api/employee/getByEmpCode | GetEmployeeByEmpCodeDto | vtrcAllProfile + centralizedProfileOrdering_reStructure_all_cdb_nbc | top-1 employee by empCode |
| 13 | GET | /api/employee/getEmployeeDisaster | GetEmployeeByEmpCodeAndDisasterStartDate | vtrcAllProfile + hrEmpWorkProfile | employee by empCode + disasterStartDate date range |
| 14 | GET | /api/employee/bankAccount | GetBankAccountDto | hrEmpBankBook_reStructure + emBank_reStructure + emBankBranch_reStructure | bank account info |
Naming inconsistency
สังเกตชื่อ route ไม่ consistent — สลับหลายรูปแบบผสมกัน
| รูปแบบ | ตัวอย่าง | จำนวน |
|---|---|---|
| คำเดียว | cu, manager, organize, level, division, department, position, group, bankAccount | 9 |
prefix pos | posManage, posTechnical | 2 |
prefix get | getByEmpCode, getEmployeeDisaster | 2 |
| (root) | / | 1 |
bankAccount เป็น camelCase ส่วน posManage เป็น prefix+Pascal → API consumer ต้องดู Swagger doc ทุกครั้ง ไม่สามารถเดา pattern ได้
@ApiBearerAuth() decorator ที่ไม่ทำงาน
ทุก method มี @ApiBearerAuth() (ที่ class level) ทำให้ Swagger UI แสดงปุ่ม Authorize ให้กรอก Bearer token แต่เนื่องจาก @Public() override guard (บท 5.5) การกรอก token หรือไม่กรอก ไม่มีผล ต่อการเรียก endpoint — ทุก request ผ่านหมด
HrPaySlipController — payslip lookup
@ApiBearerAuth()
@ApiTags('HR Pay Slip')
@Controller('hrPaySlip')
export class HrPaySlipController {
constructor(private service: HrPaySlipService) {}
@Public()
@Post()
getPaySlipDT(@Body() body: EmpCodeListDto) {
return this.service.getPaySlipDT(body);
}
}| # | Verb | Path | DTO | Source | หน้าที่ |
|---|---|---|---|---|---|
| 15 | POST | /api/hrPaySlip | EmpCodeListDto | uv_i3_PaySlipDT_reStructure view | payslip detail จาก list ของ empCode |
ใช้ POST ทั้งที่เป็น read เพราะ empCode array อาจยาวเกิน query string limit (URL length ประมาณ 2,000-8,000 bytes แล้วแต่ browser) → ส่งใน body แทน
DTO
import { IsArray, IsString } from 'class-validator';
export class EmpCodeListDto {
@IsArray()
@IsString({ each: true })
empCode: string[];
}ปัญหา — ไม่มี @ArrayMaxSize() ทำให้ caller ส่ง array ยาวเท่าไหร่ก็ได้ → ใน SQL จะสร้าง IN (@0, @1, ..., @99999) clause ที่ MSSQL อาจ reject หรือช้ามาก
HrTimeTempController — time attendance merge
@ApiBearerAuth()
@ApiTags('HR Time Temp')
@Controller('hrTimeTemp')
export class HrTimeTempController {
constructor(private service: HrTimeTempService) {}
@Public()
@Post('import')
getShiftTimeWithWork(@Body() body: GethrTimeTempQueryDto) {
return this.service.getShiftTimeWithWork(body);
}
}| # | Verb | Path | DTO | Source | หน้าที่ |
|---|---|---|---|---|---|
| 16 | POST | /api/hrTimeTemp/import | GethrTimeTempQueryDto | hrTimeTempImport (center + NBC) | time attendance merge — compute timeIn/timeOut |
ใช้ POST ด้วยเหตุผลเดียวกับ HrPaySlip — workingDate เป็น array
DTO
import { IsArray, IsDateString, ArrayMinSize } from 'class-validator';
export class ShiftTimeType {
empCode: string;
dateTimeStamp: Date;
workingDate: string;
}
export class ResponseGetShiftTimeWithWork {
empCode: string;
date: string;
timeIn: Date | null;
timeOut: Date | null;
}
export class GethrTimeTempQueryDto {
@IsArray()
@ArrayMinSize(1)
@IsDateString({}, { each: true })
workingDate: string[];
}จุดที่ดีกว่า EmpCodeListDto — มี @ArrayMinSize(1) บังคับว่า array ต้องไม่ว่าง และ @IsDateString() ตรวจ format ของแต่ละ element
ValidationPipe — global ที่ตั้งค่าถูกต้อง
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }),
);| ตัวเลือก | ค่า | ผล |
|---|---|---|
whitelist | true | ตัด property ที่ไม่มีใน DTO class ออกจาก payload |
forbidNonWhitelisted | true | throw 400 ถ้ามี property ที่ไม่มีใน DTO → ป้องกัน mass-assignment attack |
transform | (ไม่ได้ตั้ง = false) | ไม่ cast query param string เป็น number/Date อัตโนมัติ |
ข้อดี — ทุก controller ได้รับ payload ที่ผ่านการ validate แล้ว ไม่ต้อง validate เองในแต่ละ method
ข้อเสีย — transform: false ทำให้ query param ทั้งหมดเป็น string ทั้งที่ DTO declare เป็น number หรือ Date → service layer ต้อง cast เอง ถ้าลืมจะเจอ runtime error
รวบรวม DTO — 12 DTOs ใน employee.dto.ts
// สรุป class ทั้งหมด (อ่านเต็มในไฟล์)
export class GetEmployeeQueryDto { ... }
export class GetEmployeeLevelsQueryDto { ... }
export class GetTechnicalPositionsQueryDto { ... }
export class GetTechnicalPositionItemResponseDto { ... }
export class GetEmployeeGroupItemResponseDto { ... }
export class GetEmployeeGroupsQueryDto { ... }
export class GetEmployeeLevelItemResponseDto { ... }
export class GetManagersQueryDto { ... }
export class GetDivisionQueryDto { ... }
export class GetDepartmentQueryDto { ... }
export class GetPositionsQueryDto { ... }
export class GetPositionManagersQueryDto { ... }
export class EmployeeResponseDto { ... } // ← dead
export class GetEmployeeByEmpCodeDto { ... }
export class GetEmployeeByEmpCodeAndDisasterStartDate { ... } // ← ชื่อผิดรูปแบบ
export class GetBankAccountDto { ... }decorator pattern ที่ใช้
ทุก DTO ใช้ class-validator decorator ครบ — @IsString, @IsOptional, @IsDate, @IsNotEmpty, @Type(() => Date) (จาก class-transformer) สำหรับ cast
ตัวอย่าง DTO ที่เขียนดี
export class GetEmployeeByEmpCodeAndDisasterStartDate {
@IsNotEmpty()
@ApiProperty()
empCode: string;
@IsNotEmpty()
@ApiProperty()
disasterStartDate: string;
}ปัญหาที่พบ
| ปัญหา | ตำแหน่ง | รายละเอียด |
|---|---|---|
EmployeeResponseDto ไม่ได้ใช้ | dto/employee.dto.ts:72-102 | define ไว้แต่ไม่ได้เป็น return type ของ controller ไหน → dead code |
ชื่อ class ไม่มี Dto suffix | GetEmployeeByEmpCodeAndDisasterStartDate | ควรเป็น GetEmployeeByEmpCodeAndDisasterStartDateDto เพื่อให้ NestJS Swagger plugin จับได้ |
| ชื่อ class เป็นประโยคยาว | GetEmployeeByEmpCodeAndDisasterStartDate | อ่านยาก ควรสั้นกว่านี้ |
| Response type ไม่ annotated | ทุก controller | controller return type เป็น implicit any → Swagger UI จะไม่แสดง response schema ที่ถูกต้อง |
Response DTOs — ไม่ typed ใน controller
ในขณะที่ DTOs มี class สำหรับ response แล้ว (เช่น GetTechnicalPositionItemResponseDto) controller ไม่ได้ annotate return type
// สิ่งที่ควรจะเป็น
@Public()
@Get('posTechnical')
getTechnicalPositions(
@Query() query: GetTechnicalPositionsQueryDto,
): Promise<GetTechnicalPositionItemResponseDto[]> { ... }
// สิ่งที่เป็นจริง
@Public()
@Get('posTechnical')
getTechnicalPositions(@Query() query: GetTechnicalPositionsQueryDto) {
return this.technicalPositionService.getTechnicalPositions(query);
}ผล — Swagger UI จะไม่แสดง response body schema ทำให้ client ไม่ทราบรูปแบบ response ล่วงหน้า ต้องยิงจริงเพื่อดู
Mutation endpoints — ไม่มี
ยืนยันอีกครั้ง — centralize-api ไม่มี endpoint สำหรับเขียนข้อมูล ทั้งระบบ เหตุผลที่เป็นไปได้
- HRMI tables เป็นของระบบ CU/RC — VTRC ไม่มีสิทธิ์เขียนโดยตรง ถ้าจะแก้ HR data ต้องไปทำใน HRMI UI ของเจ้าของระบบ
- ออกแบบให้เป็น facade ฝั่งอ่าน — ลดความเสี่ยง เพราะไม่มีสิทธิ์ write ก็ไม่มีทางพัง HRMI โดยอุบัติเหตุ
- Write path อยู่ฝั่ง vtrc-api — welfare requests, leave docs, slip PDFs เก็บใน MariaDB ของ vtrc-api ไม่ใช่ HRMI
สรุปประเด็นสำคัญ
- 17 endpoints ใน 4 controllers ทั้งหมด read-only (GET 15 = App 1 + Employee 14; POST 2 ที่เป็น read-with-body)
- ไม่มี mutation endpoint ใด ๆ — centralize-api เป็น pure read facade
- Naming inconsistent —
posManage,posTechnical,getByEmpCode,bankAccountสลับหลายรูปแบบ - DTO coverage ดี — ทุก controller มี DTO และ ValidationPipe บังคับ
whitelist: true+forbidNonWhitelisted: true - ข้อเสีย —
transform: falseทำให้ query param เป็น string ทั้งหมด, response type ไม่ annotated ทำให้ Swagger doc ไม่สมบูรณ์ EmpCodeListDtoไม่มี@ArrayMaxSize→ caller ส่ง array ยาวเท่าไหร่ก็ได้ เสี่ยงต่อ unbounded IN clause