3.6 · Controller layer survey
บทนี้เล่าว่ามี controller อะไรบ้าง แต่ละตัวทำอะไร และจัดเป็น 7 cluster เพื่อให้หาเร็วขึ้น
ถ้าเจอ "function นี้อยู่ที่ไหน" — เริ่มที่บทนี้
ภาพรวม
api/src/lib/controllers/
├── 30 โฟลเดอร์
├── 66 ไฟล์ JS
└── ~25,800 บรรทัดรวมทุก controller ทำหน้าที่เดียวกัน — orchestration layer ที่
- รับ input จาก resolver (typeDefs)
- เรียก repository (lib/repositories) หรือ centralize/ (bridge)
- รวมผลจากหลาย source
- return กลับไป resolver
controller ไม่ควรทำ DB access ตรง — แต่ใน VTRC มีบางที่ทำ (layer inversion ดู บท 3.5)
Layering ที่ควรจะเป็น
typeDefs ─► resolvers ─► controllers ─► repositories ─► Models
│
└─► centralize/ (bridge to cu-central-api)ในทางปฏิบัติ — controller บางตัวเรียก repository + centralize + Models พร้อมกัน และบาง repository เรียก controller (layer inversion)
7 cluster ของ controllers
| Cluster | Controllers | LOC | Theme |
|---|---|---|---|
| A · Auth & Session | auth/, user/, role/, verification/, version/, consent/, policy/ | ~960 | login, JWT, RBAC, OTP |
| B · HR Master Bridge | profile/, masterData/ (8 files), organization/, timeAttendance/ | ~3,700 | read-heavy aggregator จาก centralize |
| C · Leave (HRMI) | hrmi/ | ~2,600 | leave document lifecycle |
| D · Welfare Withdraw | withdraw/ (3 files), slip/ | ~5,200 | hospital reimbursement + payroll slip |
| E · Lifecycle (Fund/Study/Conicle) | fund/, study/ (11 files), childTuition/, conicle/ | ~5,100 | provident fund, study leave, LMS |
| F · Content & Comms | post/, document/, course/, career/, contactUs/, notification/, createNotification/, sendEmail/ | ~1,700 | news, docs, courses, push notification, email |
| G · Reports & File | taxDraft/, statLog/, excel/, file/, redis.controller.js | ~2,700 | tax form, stats cron, Excel hub, file storage |
Cluster A · Auth & Session (~960 LOC)
auth/ (402 LOC)
| File | LOC | Top exports |
|---|---|---|
auth/auth.js | 333 | login, loginBackoffice, loginBypass, getPayLoad, renewRefreshToken, signOutSystem, captcha, verifyEmpCodeLogin |
auth/authSessionMoblie.js | 69 | findAuthSessionMoblie, createAccessAuthSessionMoblie (mobile public-key session) |
ดู บท 3.3 สำหรับรายละเอียด authentication internals
จุดสังเกต
authSessionMoblie.js— typo "Moblie" (ผิดจาก Mobile) เป็นชื่อไฟล์ที่ import หลายที่auth.js:20— commented-outlogTxtActive(transaction activity log disabled)
user/ (372 LOC)
| File | LOC | Top exports |
|---|---|---|
user/user.js | 330 | getUserById, createUser, changePassword, changeRole, forgetPassword, resetPassword, findEmpDetailByEmpCode |
user/userDivision.js | 42 | getCurrentUserDivision, createUserDivision, destroyUserDivision |
จุดสังเกต — forgetPassword flow ใช้ client-supplied birthDate + identityCard ผ่าน centralize checker → ต้อง verify ฝั่ง centralize ว่ามี rate limit
role/ (180 LOC)
| File | LOC | Top exports |
|---|---|---|
role/role.js | 102 | fetchRoleBackoffice, createRole, updateRole, destroyRole |
role/roleAccess.js | 78 | listRoleAccess, createRoleAccess |
จุดสังเกต — fail-open
// role/roleAccess.js (simplified)
catch (err) {
return MENUS_BACK_OFFICE // ← return all menus ตอน error
}ถ้า query มี error → return ทุก menu ทำให้ user ผ่านได้ทุกอย่าง
verification/, version/, consent/, policy/ (~140 LOC)
| File | LOC | Notes |
|---|---|---|
verification/verification.js | 38 | 100% pass-through ไป repository |
version/version.js | 51 | app version check — เปรียบเทียบ semver |
consent/consent.js | 19 | checkConsent return { data: null, isLastVerson: false } — stub ไม่ทำงานจริง |
policy/policy.js | 20 | มีแค่ commented mock block |
Cluster B · HR Master Bridge (~3,700 LOC)
cluster นี้ read-heavy — ส่วนใหญ่เป็น aggregator ที่ดึงข้อมูล HR จาก centralize แล้ว return กลับ
profile/ (321 LOC)
export ทั้งหมดเป็น 1-line pass-through ไป repositories/profile/profile.js
| Export | ใช้ตอนไหน |
|---|---|
fetchProfile | profile หลักของพนักงาน |
fetchEmployeeProfile | detail profile |
fetchEmployeeBankAccount | book bank |
fetchEmployeeProvidentFund | กองทุน |
fetchEmployeeTax | ภาษี |
fetchEmployeeFamily | ครอบครัว |
fetchEmployeeCompensation | ค่าตอบแทน |
masterData/ (8 files, 2,538 LOC)
| File | LOC | Theme |
|---|---|---|
referenceData.js | 780 | generic reference data CRUD + domain fetchers |
bank.js | 276 | bank master + deactivation guard |
bankAccount.js | 259 | hospital bank account |
masterDropdown.js | 221 | dropdown source + raw SQL (2 queries) |
masterConfig.js | 206 | provident fund window config |
approver.js | 137 | master approver per orgCode |
bankConfig.js | 78 | bank export config |
orgSystem.js | 41 | org system (ค่าคงที่องค์กร) |
จุดสังเกต — bank.js:43-72 มี referential integrity check ที่ block deactivate ของ bank ที่มี WDHospital อยู่ใน status pending
organization/ (386 LOC)
| File | LOC | Notes |
|---|---|---|
organization.js | 245 | CRUD + backoffice list |
organizationGroup.js | 96 | org group |
organizationDivision.js | 45 | division scoping |
timeAttendance/ (9 LOC — stub)
// timeAttendance/timeAttendance.js (full content essentially)
import { ... }
export const fetchTimeAttendance = async (...) => {
return await repositories.timeAttendance(...)
}มีแค่ pass-through แต่ import bcryptjs, jsonwebtoken, JWT_SECRET — unused dead imports
Cluster C · Leave HRMI (~2,600 LOC)
cluster นี้มี controller เดียว แต่ใหญ่ที่สุดเป็นอันดับ 2
hrmi/hrmi.js (2,600 LOC, 40+ exports)
| Export group | ตัวอย่าง |
|---|---|
| Medical | getMedical, hrmiGetMedicalCD |
| Approver | getApprover, getLeaveApprover |
| Leave type | getLeaveType, getLeaveDayAll |
| Leave document | createLeaveDocument, editLeaveDocument, deleteLeaveDocument, getLeaveDocumentList, getLeaveDocumentDetail |
| Attachment | hrmiCreateAttachmentFileCD, hrmiUpdateAttachmentFileCD |
| Working day | getWorkingDay |
| Helper | getTokenCDByEmpCode (ใช้ใน cluster D ด้วย) |
Business rule anchor
// leave-day validation
if (maxLeaveDay && args.leaveDay > maxLeaveDay) → reject
if (minLeaveDay && args.leaveDay < minLeaveDay) → reject
// admin bypass
if (adminDetail.orgUnitCode.substring(0,4) !== '0734' && !args.adminCanPass) {
// apply rules
}รายละเอียด business rule เต็มอยู่ใน Volume 12 Business Logic
จุดสังเกต
- 5 raw SQL
QueryTypes.SELECT(:493, 583, 1061, 2126, 2447, 2487, 2560) console.logdebug จำนวนมาก (เช่น:1286, 1289, 1330, 1337)- import
googleapis/build/src/apis/jobsที่:28ที่ดูเหมือน unused
Cluster D · Welfare Withdraw (~5,200 LOC)
cluster ใหญ่ที่สุด — เป็นหัวใจทางการเงินของระบบ
withdraw/ (3 files, 5,164 LOC)
| File | LOC | Top exports |
|---|---|---|
hospital.js | 3,179 | fetchListWDHospital, createWDHospitalv2, updateWDHospital, createDelegateApprover, importUpdateStatus (~32 exports) |
hospital.report.js | 1,378 | 12 report aggregators (getSummaryWithdrawDivisionData, getListSuccessPaymentData, ...) |
hospital.template.js | 605 | 12 PDF/Excel template renderers |
slip/ (335 LOC)
| Export | Purpose |
|---|---|
fetchSlip | ดึง payslip ของเดือน/ปี |
streamPayRollSilpPDF | stream PDF (แท้จริงแล้วสร้าง URL ให้ REST endpoint) |
fetchUrlSalaryCert | URL สำหรับ salary certificate PDF |
howToOpenPayrollSlip | Thai help text |
จุดสังเกต — bug ที่น่าสนใจ
// slip/slip.js:78 (simplified)
await new Promise(r => setTimeout(r, 3000)) // ← artificial 3s sleepdropDownSlipPayment มี artificial delay 3 วินาทีโดยไม่มีเหตุผล — คงติดมาจาก debugging แล้วลืม remove
State machine anchor
withdraw/hospital.js:2912-3025 — getActionStatus / validateUpdateStatus
withdraw/hospital.js:3126-3172 — checkMedicalExpenseRight, checkResignationRight13-state machine เต็มอยู่ใน Volume 12
Cluster E · Lifecycle — Fund/Study/Conicle (~5,100 LOC)
fund/ (1,097 LOC)
| File | LOC | Top exports |
|---|---|---|
fund.js | 927 | fetchEmployeeProvidentFund, createFundRequest, updateDelegatesExpire (cron) |
fund.report.js | 170 | report aggregator |
จุดสังเกต — fire-and-forget
export const updateDelegatesExpire = async () => {
const result = await Models.db.query('UPDATE Delegates SET isActive = 2 WHERE NOW() > endDate')
// ไม่ await — fail ก็เงียบ
}study/ (11 files, 2,894 LOC)
| File | LOC | Theme |
|---|---|---|
studyLeaveRequest.js | 1,282 | ใบลาศึกษาต่อ — main controller |
studyEducationFeeV2.js | 300 | ค่าเล่าเรียน V2 |
| อีก 9 ไฟล์ | ~310 รวม | sub-entity CRUD (detail, strategy, scholarship, ...) |
แต่ละ sub-entity มี pattern เดียวกัน — create*, update*, destroyByCondition, findByCondition, destroyByPkId
จุดสังเกต — running number race
// study/studyEducationFeeV2.js:168 (pattern)
const last = await Models.db.query('SELECT studyEducationDocumentNo FROM ... ORDER BY ... LIMIT 1')
const newNo = `FE${YYMM}-${parseInt(last) + 1}`ถ้ามี concurrent insert 2 ตัว → ทั้งคู่เห็น last = X → ทั้งคู่ generate X+1 → unique constraint error (ถ้ามี) หรือ row ซ้ำ (ถ้าไม่มี)
childTuition/ (59 LOC)
3 exports ทั้งหมดเป็น pass-through ไป centralize
conicle/ (305 LOC)
| Export | Purpose |
|---|---|
authConicle | login เข้า Conicle ด้วย cookie + csrf |
| (อื่นๆ) | sync course catalog จาก Conicle มา local Models.Conicle |
env var มี typo — CONINCLE_USERNAME vs CONICLE_PAGE (prefix ไม่เหมือนกัน)
Cluster F · Content & Comms (~1,700 LOC)
post/ (272 LOC)
news + announcement — CRUD ปกติ + highLightPost (cap 3 active highlights per division)
document/ (696 LOC)
document library — fetchListDocumentByConditions, createDocument, highLightDocument
course/, career/, contactUs/ (~580 LOC รวม)
CRUD pattern เดียวกัน — create*, update*, destroy*, highLight* + division scoping
notification/ (779 LOC)
| File | LOC | Top exports |
|---|---|---|
notification.js | 716 | fetchListNotification, sendNotification (fan-out หลาย target) |
notificationRequest.js | 39 | bulk request |
queneNotification.js | 24 | queue helper (typo "quene") |
จุดสังเกต — function พัง
export const checkRequireNotification = async (...) => {
// ...
if (requireNotification) { // ← undefined variable
// ...
}
}ถ้า code path นี้ถูกเรียก → ReferenceError
createNotification/ (33 LOC)
dispatch notification สำหรับ MEETING group
จุดสังเกต — broken catch
// createNotification/createNotification.js (simplified)
try {
// await Models.db.transaction() ← commented out
// ...
} catch (err) {
await t.rollback() // ← undefined
log.error(...) // ← log ไม่ได้ import
}ถ้า catch ถูกเรียก → ReferenceError ซ้อน
sendEmail/ (80 LOC)
SMTP email — ใช้ nodemailer + config จาก Master_configs table
จุดสังเกต
- subject มี
✔append ต่อท้ายเสมอ (:38) - error log ใช้ method name ผิด —
'Method:createStudyAttachment'(copy-paste)
Cluster G · Reports & File (~2,700 LOC)
taxDraft/ (1,944 LOC)
| Export | Purpose |
|---|---|
templateFormTax | form schema ขนาดใหญ่ (~1,500 LOC ของ JSON literal) |
fetchTaxDraft, createTaxDraft, updateTaxDraft | CRUD |
guideLineTax, confirmTextTaxDraft, detailTax | static content |
dropdownListRelation, validateInput | helpers |
~80% ของไฟล์เป็น form template literal — เหมาะกับย้ายไปเป็น i18n หรือ data-driven
statLog/ (243 LOC)
cron ที่รันทุกวันที่ 00:30 — ดึง activation log จาก END_POINT_LOG_EVENT แล้ว bulk INSERT ลง db2
จุดสังเกต — SQL injection risk
// statLog/statLog.js:36 (pattern)
const empCodeList = empCodes.map(e => `'${e}'`).join(',')
await Models.db.query(`SELECT ... WHERE empCode IN (${empCodeList})`)ถ้า log service ถูก compromise แล้วส่ง empCode ที่มี quote → SQL injection
excel/ (167 LOC + repository 614 LOC)
Excel export/import hub
| Export | Purpose |
|---|---|
downloadExcel | generic per-table download — รับ req.params.table เป็น dynamic |
createExportFile | FMIS Excel + KPayroll text + SmartCredit text + cash |
uploadExcel | read + insert |
จุดสังเกต — dynamic table
downloadExcel รับ table parameter → ผ่านไป queryDataForExcelExport(table, condition) ใน repository → Models[table].findAll(...) → ถ้า table มาจาก user input = RCE เทียบเท่า
file/ (366 LOC)
| File | LOC | Top exports |
|---|---|---|
file.js | 362 | saveFile, saveFilev2, getFileById, setUpFileBase64FromResolver |
zip.js | 4 | (debug leftover) |
จุดสังเกต — hardcoded dev path
const storagePath = "/Users/porch/Desktop/vtrc-api/api/storage"path ค้างจากเครื่อง developer ตัวเดิม — ถ้าเรียกใช้จะ fail ในทุก environment อื่น
file.js:21 — stray character
const outStream = createWriteStream(tmpFile, { flags: "w" });
W // ← stray token, expression statement (harmless but copy-paste)redis.controller.js (19 LOC, root-level)
export const getCacheData = async (name, uid) => {
tr // ← broken — ReferenceError
...
}function พัง — แต่ไม่มีใครเรียกเพราะ call site ทุกที่ comment อยู่ (ดู บท 3.4)
Layer inversion — ที่ที่ controller ถูกเรียกกลับจาก repository
ปกติ arrow ลง — controller เรียก repository แต่มี 3 กรณีที่กลับด้าน
| Repository | Controller ที่ถูกเรียก | เหตุผล |
|---|---|---|
profile/profile.js | withdraw/hospital, hrmi/hrmi, masterData/approver | profile ต้องรู้ approval chain + leave context |
slip/slip.js | masterData/mapGroupDocument | salary cert eligibility check |
notification/notification.js | masterData/referenceData | join dropdown data |
ผล — testing ทั้ง 3 repo ต้อง mock controller ที่อยู่ข้างบน
เคล็ดลับตอนทำงานจริง
ถ้าจะเพิ่ม feature ใหม่ — เริ่มที่ไหน
- เขียน typeDefs ใน
typeDefs/<domain>.js(หรือไฟล์ใหม่) - เขียน resolver ใน
resolvers/<domain>.js - เขียน controller ใน
lib/controllers/<domain>/ - เขียน repository ใน
lib/repositories/<domain>/ - ถ้าต้องเรียก centralize → เขียน wrapper ใน
centralize/controllers/<domain>.js
ห้ามข้าม layer — ถ้า resolver เรียก Models ตรง หรือ controller เรียก typeDefs จะเละ
ถ้า debug "function นี้ทำงานผิด"
เริ่มที่ typeDefs → resolver → controller → repository → Models/centralize ตามลำดับ อย่ากระโดดไปแก้ controller ก่อนเข้าใจ resolver
await arr.map(async ...) — bug ระบบ-wide
มี pattern ที่ผิดหลายไฟล์
// ผิด — await ไม่ได้รอ Promise.all
await arr.map(async (item) => {
await Models.X.create(...)
})ที่ถูก
await Promise.all(arr.map(async (item) => {
await Models.X.create(...)
}))หรือใช้ for...of ถ้าต้องการ sequential
พบใน document/documentDivision.js:8, career/careerDivision.js:9, course/courseDivision.js, post/postDivision.js, contactUs/*
Controller ที่ยาวเกินไป — แบ่งอย่างไร
withdraw/hospital.js (3,179 LOC) เป็น candidate สำหรับแบ่งเป็น
hospital.create.js— create pathhospital.update.js— update + state machinehospital.delegate.js— approval delegationhospital.list.js— list/fetchhospital.report.js(มีแล้ว)
แต่การแบ่งกระทบ caller ทุกที่ — ต้อง coordinate import update