Skip to content

3.3.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

ControllerBase pathจำนวน endpointไฟล์
AppController/1 GETcentralize-api/src/app.controller.ts
EmployeeController/employee14 GETcentralize-api/src/modules/employee/employee.controller.ts
HrPaySlipController/hrPaySlip1 POSTcentralize-api/src/modules/hrPaySlip/hrpayslip.controller.ts
HrTimeTempController/hrTimeTemp1 POSTcentralize-api/src/modules/hrTimeTemp/hrtimetemp.controller.ts

HTTP verb distribution

Verbจำนวนลักษณะ
GET15read-only (App 1 + Employee 14)
POST2read-with-body (array param ใหญ่เกิน query string)
PUT0
PATCH0
DELETE0

ยืนยันว่า centralize-api เป็น pure read facade — ไม่มี INSERT/UPDATE/DELETE SQL ใด ๆ ใน codebase (ตรวจสอบโดยค้นหาทั้งระบบ)


AppController — endpoint เดียวที่ชื่อเข้าใจผิด

centralize-api/src/app.controller.ts:17-31

typescript
@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();
  }
}
#VerbPathPublicสิ่งที่ return จริง
1GET/apiYesorg/division/department hierarchy 5-level (~130-line CTE)

ชื่อ method getHello ทำให้เข้าใจผิดว่าเป็น health check แต่ AppService.getHello() (centralize-api/src/app.service.ts:12-143) รัน raw SQL ขนาดใหญ่ที่ union ข้อมูลจาก dbHRMI_Center + dbHRMI_Center_NBC และ join 6 tables เพื่อสร้าง org hierarchy

ผลข้างเคียง — ถ้าใครเรียก /api เพื่อทดสอบว่า service รันอยู่จะกระตุ้น query หนัก ทำให้ไม่เหมาะเป็น health probe (บท 3.3.2)

สังเกต RootObject interface (centralize-api/src/app.controller.ts:7-16) ที่ define shape 9 ฟิลด์ (OrgCode, OrgUnitCode, OrgName, DiviCode, DiviName, DeptCode, DeptName, SourceDB) แต่ไม่ได้ถูกใช้เป็น return type ของอะไร → dead declaration

configService ถูก inject แต่ไม่ได้ใช้ใน method ไหน → dead injection


EmployeeController — core 14 GET endpoints

centralize-api/src/modules/employee/employee.controller.ts:27-121

typescript
@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 เต็ม

#VerbPathDTOSource dataหน้าที่
1GET/api/employeeGetEmployeeQueryDtovtrcMainProfile viewlist employees (working status filter, multi-tenant)
2GET/api/employee/cuGetEmployeeQueryDtovtrcAllProfile viewlist employees CU scope
3GET/api/employee/managerGetManagersQueryDtovtrcMainProfilemanagers by dept/org/divi code
4GET/api/employee/organize(none)org CTEdistinct org codes (top of hierarchy)
5GET/api/employee/levelGetEmployeeLevelsQueryDtoemEmplLevel_reStructureemployee levels (delegate EmployeeLevelService)
6GET/api/employee/divisionGetDivisionQueryDtoorg CTEdivisions by orgCode + sourceDB
7GET/api/employee/departmentGetDepartmentQueryDtoorg CTEdepartments by diviCode + sourceDB
8GET/api/employee/posManageGetPositionManagersQueryDtoemPositionManage_reStructureposition managers (UNION center + NBC)
9GET/api/employee/positionGetPositionsQueryDtoemPosition_reStructurepositions (UNION center + NBC)
10GET/api/employee/posTechnicalGetTechnicalPositionsQueryDtoemPositionTechnical_reStructuretechnical positions (delegate TechnicalPositionService)
11GET/api/employee/groupGetEmployeeGroupsQueryDtoemEmplGrup_reStructureemployee groups (delegate EmployeeGroupService)
12GET/api/employee/getByEmpCodeGetEmployeeByEmpCodeDtovtrcAllProfile + centralizedProfileOrdering_reStructure_all_cdb_nbctop-1 employee by empCode
13GET/api/employee/getEmployeeDisasterGetEmployeeByEmpCodeAndDisasterStartDatevtrcAllProfile + hrEmpWorkProfileemployee by empCode + disasterStartDate date range
14GET/api/employee/bankAccountGetBankAccountDtohrEmpBankBook_reStructure + emBank_reStructure + emBankBranch_reStructurebank account info

Naming inconsistency

สังเกตชื่อ route ไม่ consistent — สลับหลายรูปแบบผสมกัน

รูปแบบตัวอย่างจำนวน
คำเดียวcu, manager, organize, level, division, department, position, group, bankAccount9
prefix posposManage, posTechnical2
prefix getgetByEmpCode, getEmployeeDisaster2
(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 (บท 3.3.5) การกรอก token หรือไม่กรอก ไม่มีผล ต่อการเรียก endpoint — ทุก request ผ่านหมด


HrPaySlipController — payslip lookup

centralize-api/src/modules/hrPaySlip/hrpayslip.controller.ts:11-22

typescript
@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);
  }
}
#VerbPathDTOSourceหน้าที่
15POST/api/hrPaySlipEmpCodeListDtouv_i3_PaySlipDT_reStructure viewpayslip detail จาก list ของ empCode

ใช้ POST ทั้งที่เป็น read เพราะ empCode array อาจยาวเกิน query string limit (URL length ประมาณ 2,000-8,000 bytes แล้วแต่ browser) → ส่งใน body แทน

DTO

centralize-api/src/dto/hrpayslip.dto.ts:1-7

typescript
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

centralize-api/src/modules/hrTimeTemp/hrtimetemp.controller.ts:11-22

typescript
@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);
  }
}
#VerbPathDTOSourceหน้าที่
16POST/api/hrTimeTemp/importGethrTimeTempQueryDtohrTimeTempImport (center + NBC)time attendance merge — compute timeIn/timeOut

ใช้ POST ด้วยเหตุผลเดียวกับ HrPaySlipworkingDate เป็น array

DTO

centralize-api/src/dto/hrTimeTemp.dto.ts:1-29

typescript
import {
  ArrayMinSize,
  IsArray,
  IsDateString,
  IsNotEmpty,
  IsString,
} from 'class-validator';

export class GethrTimeTempQueryDto {
  @IsString()
  @IsNotEmpty()
  empCode: string;

  @IsArray()
  @IsDateString(null, { each: true })
  @ArrayMinSize(1)
  workingDate: Date[];
}

export interface ShiftTimeType {
  empCode: string;
  dateTimeStamp: Date;
}

export interface ResponseGetShiftTimeWithWork {
  date: string;
  timeIn: string;
  timeOut: string;
}

จุดที่ดีกว่า EmpCodeListDto — มี @ArrayMinSize(1) บังคับว่า array ต้องไม่ว่าง และ @IsDateString() ตรวจ format ของแต่ละ element

สังเกต — v1 เอกสารเก่ากว่าโค้ดปัจจุบัน 3 จุด (1) v1 claim DTO มีแค่ workingDate field แต่จริง ๆ มี empCode required field ด้วย (2) v1 บอก ShiftTimeType/ResponseGetShiftTimeWithWork เป็น class แต่จริง ๆ เป็น interface (3) v1 บอก timeIn/timeOut เป็น Date | null แต่จริง ๆ เป็น string


ValidationPipe — global ที่ตั้งค่าถูกต้อง

centralize-api/src/main.ts:28-30

typescript
app.useGlobalPipes(
  new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }),
);
ตัวเลือกค่าผล
whitelisttrueตัด property ที่ไม่มีใน DTO class ออกจาก payload
forbidNonWhitelistedtruethrow 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

ตัวอย่างที่เจอจริง — GetEmployeeByEmpCodeAndDisasterStartDate.disasterStartDate มี @IsDate() + @Type(() => Date) (centralize-api/src/dto/employee.dto.ts:132-142) แต่เพราะ transform: false decorator เหล่านี้ไม่ทำงาน → field จะมาเป็น string ใน service


รวบรวม DTO — 16 class/interface ใน employee.dto.ts

centralize-api/src/dto/employee.dto.ts:1-221

typescript
// สรุป class ทั้งหมด (อ่านเต็มในไฟล์)
export class GetEmployeeQueryDto { ... }                              // 9 optional fields
export class GetEmployeeLevelsQueryDto { ... }                        // 1 field (sourceDB?)
export class GetTechnicalPositionsQueryDto { ... }                    // 1 field (sourceDB?)
export class GetTechnicalPositionItemResponseDto { ... }              // 6 fields
export class GetEmployeeGroupItemResponseDto { ... }                  // 6 fields
export class GetEmployeeGroupsQueryDto { ... }                        // 1 field (sourceDB?)
export class GetEmployeeLevelItemResponseDto { ... }                  // 6 fields
export class GetManagersQueryDto { ... }                              // 4 optional fields
export class GetDivisionQueryDto { ... }                              // 2 required fields
export class GetDepartmentQueryDto { ... }                            // 2 required fields
export class GetPositionsQueryDto { ... }                             // 1 field (sourceDB?)
export class GetPositionManagersQueryDto { ... }                      // 1 field (sourceDB?)
export class EmployeeResponseDto { ... }                              // ← dead
export class GetEmployeeByEmpCodeDto { ... }                          // 1 required field
export class GetEmployeeByEmpCodeAndDisasterStartDate { ... }         // ← ชื่อผิดรูปแบบ
export class GetBankAccountDto { ... }                                // 3 required fields

decorator pattern ที่ใช้

ทุก DTO ใช้ class-validator decorator ครบ — @IsString, @IsOptional, @IsDate, @IsNotEmpty, @Type(() => Date) (จาก class-transformer) สำหรับ cast, @ApiProperty() จาก @nestjs/swagger (บางตัวมีบางตัวไม่มี — Swagger plugin จะเติมให้อัตโนมัติอยู่แล้ว)

ตัวอย่าง DTO ที่เขียนดี

centralize-api/src/dto/employee.dto.ts:132-142

typescript
export class GetEmployeeByEmpCodeAndDisasterStartDate {
  @ApiProperty()
  @IsString()
  empCode!: string;

  @ApiProperty({ name: 'disasterStartDate' })
  @IsDate()
  @IsOptional()
  @Type(() => Date)
  disasterStartDate!: Date;
}

ปัญหาที่พบ

ปัญหาตำแหน่งรายละเอียด
EmployeeResponseDto ไม่ได้ใช้centralize-api/src/dto/employee.dto.ts:72-102define ไว้แต่ไม่ได้เป็น return type ของ controller ไหน → dead code
ชื่อ class ไม่มี Dto suffixGetEmployeeByEmpCodeAndDisasterStartDateควรเป็น GetEmployeeByEmpCodeAndDisasterStartDateDto เพื่อให้ NestJS Swagger plugin จับได้ (ปัจจุบัน plugin trigger จากชื่อไฟล์ .dto.ts ไม่ใช่ชื่อ class → ยังทำงานได้อยู่)
ชื่อ class เป็นประโยคยาวGetEmployeeByEmpCodeAndDisasterStartDateอ่านยาก ควรสั้นกว่านี้
Response type ไม่ annotatedทุก controllercontroller return type เป็น implicit any → Swagger UI จะไม่แสดง response schema ที่ถูกต้อง

Response DTOs — ไม่ typed ใน controller

ในขณะที่ DTOs มี class สำหรับ response แล้ว (เช่น GetTechnicalPositionItemResponseDto) controller ไม่ได้ annotate return type

typescript
// สิ่งที่ควรจะเป็น
@Public()
@Get('posTechnical')
getTechnicalPositions(
  @Query() query: GetTechnicalPositionsQueryDto,
): Promise<GetTechnicalPositionItemResponseDto[]> { ... }

// สิ่งที่เป็นจริง (centralize-api/src/modules/employee/employee.controller.ts:92-96)
@Public()
@Get('posTechnical')
getTechnicalPositions(@Query() query: GetTechnicalPositionsQueryDto) {
  return this.technicalPositionService.getTechnicalPositions(query);
}

ผล — Swagger UI จะไม่แสดง response body schema ทำให้ client ไม่ทราบรูปแบบ response ล่วงหน้า ต้องยิงจริงเพื่อดู


Mutation endpoints — ไม่มี

ยืนยันอีกครั้ง — centralize-api ไม่มี endpoint สำหรับเขียนข้อมูล ทั้งระบบ เหตุผลที่เป็นไปได้

  1. HRMI tables เป็นของระบบ CU/RC — VTRC ไม่มีสิทธิ์เขียนโดยตรง ถ้าจะแก้ HR data ต้องไปทำใน HRMI UI ของเจ้าของระบบ
  2. ออกแบบให้เป็น facade ฝั่งอ่าน — ลดความเสี่ยง เพราะไม่มีสิทธิ์ write ก็ไม่มีทางพัง HRMI โดยอุบัติเหตุ
  3. 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