Skip to content

3.1.6 · Controller layer survey

บทนี้เล่าว่ามี controller อะไรบ้าง แต่ละตัวทำอะไร จัดเป็น 7 cluster เพื่อให้ค้นเร็วขึ้น ถ้าเจอคำถามแบบ "function นี้อยู่ที่ไหน" ให้เริ่มที่บทนี้ บทนี้จะลงลึกถึง pattern ที่พบบ่อยในแต่ละ cluster (raw SQL, fire-and-forget, await arr.map, broken catch) พร้อม code trace และ line citation เพื่อให้ตอบโจทย์ทั้งคนอ่านใหม่และคนมา debug


ภาพรวม (verify กับ filesystem 2026-07-13)

api/src/lib/controllers/
├── 31 โฟลเดอร์ + 1 ไฟล์เดี่ยว (redis.controller.js)
├── 69 ไฟล์ JS รวม
└── 20,698 บรรทัดรวม (ตรงกับ canonical-truths G4)

แก้จาก v1 — v1 ระบุ "30 โฟลเดอร์, 66 ไฟล์, ~25,800 บรรทัด" นับใหม่จาก filesystem ได้ 31 โฟลเดอร์, 69 ไฟล์ (รวม redis.controller.js ที่เป็นไฟล์เดี่ยว), 20,698 บรรทัด — จำนวนโฟลเดอร์ตรงกับ canonical-truths G4 ส่วน LOC ที่ต่างกันมาก (25,800 → 20,698) มาจากการนับผิดของ v1 ไม่ใช่โค้ดหด ยืนยันด้วย find lib/controllers -name "*.js" -exec wc -l {} +

ทุก controller ทำหน้าที่เป็น orchestration layer ที่ (1) รับ input จาก resolver (2) เรียก repository หรือ centralize/ bridge (3) รวมผลจากหลาย source (4) return กลับไป resolver controller ไม่ควรทำ DB access ตรง แต่ใน VTRC มีบางไฟล์ที่ทำ (layer inversion — ดู 3.1.5)

typeDefs  ─►  resolvers  ─►  controllers  ─►  repositories  ─►  Models

                                                    └─► centralize/ (bridge to cu-central-api)

ในทางปฏิบัติ controller บางตัวเรียก repository + centralize + Models พร้อมกัน และบาง repository เรียก controller กลับ (layer inversion) เป็นสาเหตุหลักที่ทำให้ unit-test controller ยาก เพราะ mock ต้องครอบคลุมหลายชั้น


7 cluster ของ controllers

ClusterControllersLOC (verified)Theme
A · Auth & Sessionauth/(402), user/(372), role/(180), verification/(38), version/(51), consent/(19), policy/(20)~1,082login, JWT, RBAC, OTP
B · HR Master Bridgeprofile/(321), masterData/(1,998, 8 files), organization/(386), timeAttendance/(9)~2,714read-heavy aggregator จาก centralize
C · Leave (HRMI)hrmi/(2,600)2,600leave document lifecycle
D · Welfare Withdrawwithdraw/(5,162, 3 files), slip/(335)~5,497hospital reimbursement + payroll slip
E · Lifecyclefund/(1,097), study/(2,008, 11 files), childTuition/(59), conicle/(305)~3,469provident fund, study leave, LMS
F · Content & Commspost/(272), document/(696), course/(140), career/(119), contactUs/(326), notification/(779), createNotification/(33), sendEmail/(80)~2,445news, docs, courses, push, email
G · Reports & FiletaxDraft/(1,944), statLog/(243), excel/(167), file/(366), redis.controller.js(19, root)~2,739tax form, stats cron, Excel hub, file storage

Cluster A · Auth & Session

auth/ (402 LOC, 2 ไฟล์)

auth/auth.js (333 LOC) export หลักคือ login, loginBackoffice, loginBypass, getPayLoad, renewRefreshToken, signOutSystem, captcha, verifyEmpCodeLogin — ดู 3.1.3 สำหรับรายละเอียด authentication internals

auth/authSessionMoblie.js (69 LOC) — เก็บ mobile public-key session (findAuthSessionMoblie, createAccessAuthSessionMoblie) ชื่อไฟล์มี typo "Moblie" (ผิดจาก Mobile) แต่เป็นชื่อที่ import หลายที่ การแก้ typo นี้ต้อง update import ทุก caller พร้อมกัน

vtrc-api/api/src/lib/controllers/auth/auth.js:20 — มี logTxtActive แบบ commented-out ค้างอยู่ คือ transaction activity log ที่ถูก disable ไว้

user/ (372 LOC, 2 ไฟล์)

user/user.js (330 LOC) export หลักคือ getUserById, createUser, changePassword, changeRole, forgetPassword, resetPassword, findEmpDetailByEmpCode, findEmpDetailByEmpCodeArray

จุดสังเกต — forgetPassword flow ใช้ client-supplied birthDate + identityCard ผ่าน centralize checker เพื่อ verify ตัวตนก่อน reset ระบบจึง rely ฝั่ง centralize ว่ามี rate limit ถ้า centralize ไม่ limit → brute force birthDate+identityCard ได้

user/userDivision.js (42 LOC) — getCurrentUserDivision, createUserDivision, destroyUserDivision

role/ (180 LOC, 2 ไฟล์) — fail-open anti-pattern

vtrc-api/api/src/lib/controllers/role/roleAccess.js

javascript
// pattern — simplified
catch (err) {
  return MENUS_BACK_OFFICE  // ← return all menus ตอน error
}

ถ้า query มี error (DB down, syntax error, timeout) → catch block return ทุก menu ทำให้ user ที่ login อยู่ตอนนั้นผ่านเข้า menu ได้ทุกอย่าง เป็น security anti-pattern คลาสสิก (fail-open) ที่ควรเป็น fail-closed (return [] หรือ rethrow)

role/role.js (102 LOC) — fetchRoleBackoffice, createRole, updateRole, destroyRole

FileLOCNotes
verification/verification.js38100% pass-through ไป repository
version/version.js51app version check — เปรียบเทียบ semver
consent/consent.js19checkConsent return { data: null, isLastVerson: false }stub ไม่ทำงานจริง (เห็น typo isLastVerson ด้วย)
policy/policy.js20มีแค่ commented mock block

cluster นี้เป็น "API surface ที่วางไว้แต่ยังไม่ implement" จริง — มี resolver, typeDefs, controller ครบ แต่ logic ว่าง


Cluster B · HR Master Bridge

cluster นี้ read-heavy — ส่วนใหญ่เป็น aggregator ที่ดึงข้อมูล HR จาก centralize แล้ว return กลับ แทบไม่มี business logic เป็นของตัวเอง

profile/ (321 LOC)

export ทั้งหมดเป็น 1-line pass-through ไป repositories/profile/profile.js

Exportใช้ตอนไหน
fetchProfileprofile หลักของพนักงาน
fetchEmployeeProfiledetail profile
fetchEmployeeBankAccountbook bank
fetchEmployeeProvidentFundกองทุน
fetchEmployeeTaxภาษี
fetchEmployeeFamilyครอบครัว
fetchEmployeeCompensationค่าตอบแทน

ไฟล์นี้เป็นที่ที่ layer inversion เกิด — repository เรียก controller อื่นกลับ (ดูหัวข้อ Layer inversion ด้านล่าง)

masterData/ (8 files, 1,998 LOC)

FileLOCTheme
referenceData.js780generic reference data CRUD + domain fetchers
bank.js276bank master + deactivation guard
bankAccount.js259hospital bank account
masterDropdown.js221dropdown source + raw SQL (2 queries)
masterConfig.js206provident fund window config
approver.js137master approver per orgCode
bankConfig.js78bank export config
orgSystem.js41org system constant

จุดสังเกต — bank.js:43-72 มี referential integrity check ที่ block deactivate ของ bank ที่มี WDHospital อยู่ใน status pending เป็น guard ที่ดี (ป้องกันลบ master ที่มี transaction reference)

organization/ (386 LOC, 3 ไฟล์)

organization.js (245 LOC) — CRUD + backoffice list organizationGroup.js (96 LOC) — org group organizationDivision.js (45 LOC) — division scoping

timeAttendance/ (9 LOC — stub)

javascript
// timeAttendance/timeAttendance.js (full content essentially)
import { ... }  // bcryptjs, jsonwebtoken, JWT_SECRET
export const fetchTimeAttendance = async (...) => {
  return await repositories.timeAttendance(...)
}

มีแค่ pass-through แต่ import bcryptjs, jsonwebtoken, JWT_SECRET โดยไม่ได้ใช้ — unused dead imports ค้างจาก copy-paste จาก controller อื่น เป็น load เล็กน้อยตอน module load แต่อันตรายตรงที่ทำให้คนอ่านเข้าใจผิดว่ามี auth logic


Cluster C · Leave HRMI

cluster นี้มี controller เดียว แต่ใหญ่เป็นอันดับ 2 ของระบบ

hrmi/hrmi.js (2,600 LOC, 40+ exports)

Export groupตัวอย่าง
MedicalgetMedical, hrmiGetMedicalCD
ApprovergetApprover, getLeaveApprover
Leave typegetLeaveType, getLeaveDayAll
Leave documentcreateLeaveDocument, editLeaveDocument, deleteLeaveDocument, getLeaveDocumentList, getLeaveDocumentDetail
AttachmenthrmiCreateAttachmentFileCD, hrmiUpdateAttachmentFileCD
Working daygetWorkingDay
HelpergetTokenCDByEmpCode (ใช้ใน cluster D ด้วย — cross-cluster coupling)

Business rule anchor — leave-day validation

vtrc-api/api/src/lib/controllers/hrmi/hrmi.js:1295-1319

javascript
// pattern — simplified
// 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 14 Business Logic

จุดสังเกต

  • 5+ raw SQL QueryTypes.SELECT กระจายอยู่ (:493, 583, 1061, 2126, 2447, 2487, 2560) — bypass repository layer
  • console.log debug จำนวนมาก (เช่น :1286, 1289, 1330, 1337) — ไม่ใช่ structured logging
  • vtrc-api/api/src/lib/controllers/hrmi/hrmi.js:28 import { jobs } from 'googleapis/build/src/apis/jobs' ที่ไม่ได้ใช้ในไฟล์ — load module ทั้ง Google API library เปล่าๆ ทำให้ cold start ช้าลง

Cluster D · Welfare Withdraw

cluster ใหญ่ที่สุดในระบบ — เป็นหัวใจทางการเงินของระบบ (hospital reimbursement + payroll slip)

withdraw/ (3 files, 5,162 LOC)

FileLOCTop exports
hospital.js3,179fetchListWDHospital, createWDHospitalv2, updateWDHospital, createDelegateApprover, importUpdateStatus (~32 exports)
hospital.report.js1,37812 report aggregators (getSummaryWithdrawDivisionData, getListSuccessPaymentData, ...)
hospital.template.js60512 PDF/Excel template renderers

ไฟล์ hospital.js import หนักมาก 30+ modules (ดู header :1-29) รวม controller อื่น (user/user, notification/notification, hrmi/hrmi, excel/excel, file/file) และ centralize bridge (centralize/controllers/user) — เป็น god-controller ที่ coupling สูง

vtrc-api/api/src/lib/controllers/withdraw/hospital.js:31-34

javascript
const MEDICAL_NOTI_TYPE = {
  detail: "MEDICAL_DETAIL",
  approving: "MEDICAL_APPROVING"
}

constant นี้ใช้ใน notification dispatch ตอนสร้าง/อนุมัติ WD — เป็น business rule ที่ embed ใน controller แทนที่จะอยู่ใน config

slip/ (335 LOC)

ExportPurpose
fetchSlipดึง payslip ของเดือน/ปี
streamPayRollSilpPDFstream PDF (แท้จริงแล้วสร้าง URL ให้ REST endpoint)
fetchUrlSalaryCertURL สำหรับ salary certificate PDF
howToOpenPayrollSlipThai help text อธิบายรหัสผ่าน DDMMYYYY
dropDownSlipPaymentdropdown ประเภทเอกสาร

จุดสังเกต — artificial delay

vtrc-api/api/src/lib/controllers/slip/slip.js:78

javascript
await new Promise(r => setTimeout(r, 3000))  // ← artificial 3s sleep

dropDownSlipPayment มี artificial delay 3 วินาทีโดยไม่มีเหตุผล คงติดมาจาก debugging แล้วลืม remove — ทุกครั้งที่ frontend เปิดหน้า slip dropdown ต้องรอ 3 วินาทีเสมอ

vtrc-api/api/src/lib/controllers/slip/slip.js:307

javascript
await new Promise(r => setTimeout(r, 3000))  // ← duplicate ใน dropDownTypeSalaryCert

delay ซ้ำในฟังก์ชัน dropDownTypeSalaryCert ด้วย — เป็น pattern ที่ copy-paste กัน

State machine anchor

withdraw/hospital.js:2912-3025 — getActionStatus / validateUpdateStatus
withdraw/hospital.js:3126-3172 — checkMedicalExpenseRight, checkResignationRight

13-state machine เต็มอยู่ใน Volume 14 Business Logic


Cluster E · Lifecycle — Fund/Study/Conicle

cluster ที่ครอบคลุม lifecycle ของพนักงาน (กองทุน, ลาศึกษา, LMS)

fund/ (2 files, 1,097 LOC)

fund.js (927 LOC) export หลักคือ fetchEmployeeProvidentFund, createFundRequest, updateDelegatesExpire (cron — ดู 3.1.8) fund.report.js (170 LOC) — report aggregator

จุดสังเกต — fire-and-forget (CORR-VTRC-API-01 pattern)

vtrc-api/api/src/lib/controllers/fund/fund.js:926-928

javascript
export const updateDelegatesExpire = () => {
    Models.db.query(`UPDATE vtrc.Delegates SET isActive = :isActive  WHERE  NOW()  >  endDate and isActive != 0`, { replacements: { isActive: 2 }, type: QueryTypes.UPDATE })
}

function นี้ไม่ async, ไม่ return promise ที่ caller จะ await ได้ — caller ทุกที่ (cron) เรียกแบบ sync ปล่อยให้ query ทำงานเอง ถ้า query fail → unhandledRejection ที่ไม่มีใครเห็น ไม่มี log ไม่มี retry เป็น pattern เดียวกับ CORR-VTRC-API-01 (transaction misuse) ที่พบใน audit

Running number generation

vtrc-api/api/src/lib/controllers/fund/fund.js:915-924

javascript
const fundRequestNoCondition = { where: { fundRequestNo: { [Op.like]: fundRequestNo + "%" } }, order: [["fundRequestNo", "DESC"]] }
const fundRequestDatas = await FundRequest.findByConditions(fundRequestNoCondition)
if (fundRequestDatas.length > 0) {
    fundRequestNo += padLeadingZeros(fundRequestDatas[0].fundRequestOrder + 1, 6)
} else {
    fundRequestNo += padLeadingZeros(1, 6)
}

generate running number จาก SELECT ... ORDER BY ... LIMIT 1 แล้ว +1 — ถ้ามี concurrent insert 2 ตัวพร้อมกัน ทั้งคู่เห็น last = X → generate X+1 ซ้ำกัน → unique constraint error (ถ้ามี) หรือ row ซ้ำ (ถ้าไม่มี)

study/ (11 files, 2,008 LOC)

FileLOCTheme
studyLeaveRequest.js1,282ใบลาศึกษาต่อ — main controller
studyEducationFeeV2.js300ค่าเล่าเรียน V2
อีก 9 ไฟล์~426 รวมsub-entity CRUD (detail, strategy, scholarship, ...)

แต่ละ sub-entity มี pattern เดียวกัน — create*, update*, destroyByCondition, findByCondition, destroyByPkId เป็น CRUD scaffold ที่ generate จาก template เดียวกัน

study/studyEducationFeeV2.js:168 มี running-number race pattern เดียวกับ fund.js:915 — SELECT last + 1 โดยไม่มี lock

childTuition/ (59 LOC) และ conicle/ (305 LOC)

childTuition — 3 exports ทั้งหมดเป็น pass-through ไป centralize

conicle/authConicle login เข้า Conicle ด้วย cookie + csrf, sync course catalog จาก Conicle มา local Models.Conicle env var มี typo — CONINCLE_USERNAME (ตัว N เกิน) vs CONICLE_PAGE (prefix ไม่ตรงกัน) ใช้ใน config คนละจุด


Cluster F · Content & Comms

cluster ที่เก็บ content และ push communication ออกไปยัง user

post/ (272 LOC) และ document/ (696 LOC)

post/ — news + announcement — CRUD ปกติ + highLightPost (cap 3 active highlights per division)

document/fetchListDocumentByConditions, createDocument, highLightDocument มี documentDivision.js ที่ใช้ await arr.map(async ...) (ดูหัวข้อ bug ระบบ-wide ด้านล่าง)

course/, career/, contactUs/ (~585 LOC รวม)

CRUD pattern เดียวกันทั้งหมด — create*, update*, destroy*, highLight* + division scoping

notification/ (3 files, 779 LOC)

FileLOCTop exports
notification.js716fetchListNotification, sendNotification (fan-out หลาย target)
notificationRequest.js39bulk request
queneNotification.js24queue helper (typo "quene" แทน "queue")

จุดสังเกต — ReferenceError (QUAL-VTRC-API-05)

vtrc-api/api/src/lib/controllers/notification/notification.js:127-134

javascript
export const checkRequireNotification = async (context) => {
  const notificationUserSetting = await findByConditions(context.userId)
  if (notificationUserSetting != null) {
    return requireNotification.requireNotification
  } else {
    return false
  }
}

function นี้อ้างถึง requireNotification.requireNotification โดยที่ไม่มี variable/module ชื่อนี้ใน scope ของไฟล์ (ไม่มี import, ไม่มี parameter) ถ้า code path นี้ถูกเรียกและเข้าเส้นทางที่ return → เกิด ReferenceError: requireNotification is not defined

sendNotification fan-out pattern

vtrc-api/api/src/lib/controllers/notification/notification.js:146-215

javascript
export const sendNotification = async (args) => {
  let { statusContent, conditions } = await checkPublicOrInternalAndConditions(args)
  if (statusContent === "INTERNAL") {
    userInternal = await getUserNotificationInternal(args, conditions)
    resultSendingInternalDeviceToken = await firebasePushNotification(args, ...)
  } else if (statusContent === "ALLINTERNAL") {
    // ...
  } else {
    // send noti all PUBLIC device and INTERNAL division
  }
  // ...
}

sendNotification แยกเส้นทาง INTERNAL / ALLINTERNAL / PUBLIC+INTERNAL แล้วเรียก firebasePushNotification สองรอบ (public device + internal device) — เป็น fan-out manual ที่ถ้ารอบแรก success รอบสอง fail จะไม่ rollback log ของรอบแรก

createNotification/ (33 LOC)

vtrc-api/api/src/lib/controllers/createNotification/createNotification.js:1-34

javascript
export const createNoti = async (args, context) => {
  // const t = await Models.db.transaction()
  try {
      if (args.notificationGroup === "MEETING") {
        // ...
        await notification.sendNotificationByThirdParty(...)
      }
    return { status: true }
  } catch (error) {
    console.log(error)
    await t.rollback()  // ← undefined
    const errorCode = error.extensions ? error.extensions.code : null
    if (errorCode !== null) {
      log.error(...)  // ← log ไม่ได้ import
    } else {
      log.error(...)  // ← log ไม่ได้ import
      responError('ERROR')
    }
  }
}

สังเกตว่า transaction creation ถูก comment ไว้ (:4) แต่ catch block ยังเรียก t.rollback() (:24) — ถ้า error เกิดขึ้น → ReferenceError: t is not defined ซ้อนทับ error เดิม และ log ก็ไม่ได้ import → ReferenceError ซ้อนอีกชั้น สุดท้าย catch block ไม่มีวันทำงานถูกต้อง

sendEmail/ (80 LOC)

SMTP email — ใช้ nodemailer + config จาก Master_configs table

จุดสังเกต:

  • subject มี append ต่อท้ายเสมอ (:38) — hardcoded emoji ในโค้ด
  • error log ใช้ method name ผิด — 'Method:createStudyAttachment' (copy-paste จากไฟล์อื่น) ทำให้ log บอก method ผิด ตอน debug ต้อง cross-reference

Cluster G · Reports & File

cluster สำหรับ report generation + file storage + cache layer (ที่ตาย)

taxDraft/ (1,944 LOC)

ExportPurpose
templateFormTaxform schema ขนาดใหญ่ (~1,500 LOC เป็น JSON literal)
fetchTaxDraft, createTaxDraft, updateTaxDraftCRUD
guideLineTax, confirmTextTaxDraft, detailTaxstatic content
dropdownListRelation, validateInputhelpers

~80% ของไฟล์เป็น form template literal — เหมาะกับย้ายไปเป็น i18n resource หรือ data-driven config แทนการ embed ใน controller

statLog/ (243 LOC)

cron ที่รันทุกวันที่ 00:30 — ดึง activation log จาก END_POINT_LOG_EVENT แล้ว bulk INSERT ลง db2 (ดู 3.1.8 สำหรับรายละเอียด cron schedule)

จุดสังเกต — SQL injection risk

vtrc-api/api/src/lib/controllers/statLog/statLog.js:36 (pattern)

javascript
const empCodeList = empCodes.map(e => `'${e}'`).join(',')
await Models.db.query(`SELECT ... WHERE empCode IN (${empCodeList})`)

ถ้า log service ถูก compromise แล้วส่ง empCode ที่มี single-quote (เช่น '); DROP TABLE x;--) → SQL injection เข้า vtrc database ทางอ้อม ควรใช้ Sequelize replacements แทน string concat

excel/ (167 LOC + repository 614 LOC)

Excel export/import hub

ExportPurpose
downloadExcelgeneric per-table download — รับ req.params.table เป็น dynamic
createExportFileFMIS Excel + KPayroll text + SmartCredit text + cash
uploadExcelread + insert

จุดสังเกต — dynamic table (RCE equivalent)

downloadExcel รับ table parameter → ผ่านไป queryDataForExcelExport(table, condition) ใน repository → Models[table].findAll(...) ถ้า table มาจาก user input โดยตรง (ไม่ whitelist) = เทียบเท่า RCE เพราะ attacker สามารถอ่าน table อะไรก็ได้ใน DB (PII, password hash, token)

file/ (2 files, 366 LOC)

file.js (362 LOC) — saveFile, saveFilev2, getFileById, setUpFileBase64FromResolver, convertStreamFileToBase64File zip.js (4 LOC — debug leftover)

จุดสังเกต — hardcoded dev path

vtrc-api/api/src/lib/controllers/file/zip.js:4

javascript
    return await generateZipFile("/Users/porch/Desktop/vtrc-api/api/storage")

path ค้างจากเครื่อง developer ตัวเดิม (/Users/porch/Desktop/...) — ยัง exist ในโค้ดปัจจุบัน (verify 2026-07-13) ถ้าเรียกใช้จะ fail ในทุก environment อื่น เพราะ path นั้นไม่มีอยู่จริงบนเครื่องอื่น

file.js — stray token

vtrc-api/api/src/lib/controllers/file/file.js:21

javascript
const outStream = createWriteStream(tmpFile, { flags: "w" });
W  // ← stray token, expression statement (harmless but copy-paste leftover)

stray character W บรรทัดเดี่ยว — เป็น expression statement ที่ไม่ทำอะไร (evaluate แล้ว discard) แต่บ่งบอกว่ามี copy-paste ไม่สะอาด

redis.controller.js (19 LOC, root-level)

vtrc-api/api/src/lib/controllers/redis.controller.js:15-20

javascript
export const getCacheData = async (cacheKeyName, uid) => {
  tr
  const cacheId = buildDataCacheId(cacheKeyName, uid)
  const data = await redisGet(cacheId)
  return data
}

tr บรรทัด 16 — เป็น token เดี่ยวที่ไม่ใช่ keyword ใดใน JavaScript ถ้า function นี้ถูกเรียก → ReferenceError: tr is not defined ยืนยันโค้ดยังเป็นแบบนี้อยู่ (verify 2026-07-13) — แต่ทุก caller ถูก comment หมดแล้ว (ดู 3.1.4) จึงไม่ crash จริงใน production

เป็นไฟล์เดี่ยวที่อยู่นอกโฟลเดอร์ cluster — ควรย้ายเข้า file/ หรือสร้าง cache/ หรือลบทิ้งถ้า dead code


Pattern ที่พบบ่อย (system-wide issues)

CORR-VTRC-API-01 · Transaction misuse

หลาย controller มี pattern ที่ประกาศ transaction แต่ไม่ commit/rollback ครบ chain:

javascript
// pattern — simplified
const t = await Models.db.transaction()
await Models.A.create({...}, { transaction: t })
await Models.B.create({...})  // ← ลืม { transaction: t }
await t.commit()

กรณี B fail → A rollback แต่สิ่งที่ B สร้างใน chain อื่น (เช่น notification, file) ไม่ rollback พบใน withdraw/hospital.js, createNotification/createNotification.js

QUAL-VTRC-API-04 · await arr.map(async ...)

javascript
// ผิด — await ไม่ได้รอ Promise.all
await arr.map(async (item) => {
  await Models.X.create(...)
})

Array.map return array ของ Promise แต่ await array (ไม่ใช่ Promise) return ทันที — การ create ทุกตัวทำงาน parallel โดยไม่รอ และ error ใด error หนึ่งจะกลายเป็น unhandledRejection

ที่ถูก:

javascript
await Promise.all(arr.map(async (item) => {
  await Models.X.create(...)
}))

หรือใช้ for...of ถ้าต้องการ sequential

พบใน document/documentDivision.js, career/careerDivision.js, course/courseDivision.js, post/postDivision.js, contactUs/* — เป็น pattern ที่ copy กันจนกลายเป็น de-facto convention

QUAL-VTRC-API-05 · Fire-and-forget query

fund.js:926 เป็นตัวอย่างชัดเจน — function ไม่ async, เรียก Models.db.query() (return Promise) แต่ไม่ return ออกมา caller เรียกแบบ sync หาก query fail → silent rejection

พบใน cron-related controller (fund.js, statLog.js), notification dispatch (notification.js บาง branch)

Debug artifacts ที่ค้างในโค้ด

ประเภทตำแหน่งImpact
Hardcoded dev pathfile/zip.js:4ไม่ทำงานบนเครื่องอื่น
Stray tokenfile/file.js:21 (W), redis.controller.js:16 (tr)ReferenceError ถ้าถูกเรียก
Artificial delayslip/slip.js:78, slip/slip.js:307UX ช้า 3 วินาทีทุกครั้ง
Commented-out transaction + broken catchcreateNotification/createNotification.js:4,24catch block พัง
console.log debughrmi/hrmi.js (หลายจุด), slip/slip.js:319log noise ใน production
Unused importhrmi/hrmi.js:28 (googleapis/build/src/apis/jobs), timeAttendance/timeAttendance.js (bcryptjs, jsonwebtoken)cold start ช้า
Typoauth/authSessionMoblie.js (Moblie), notification/queneNotification.js (quene), conicle env (CONINCLE_)ค้นหายาก, refactor เสี่ยง

Layer inversion — ที่ที่ controller ถูกเรียกกลับจาก repository

ปกติ arrow ลง — controller เรียก repository แต่มี 3 กรณีที่กลับด้าน

RepositoryController ที่ถูกเรียกเหตุผล
profile/profile.jswithdraw/hospital, hrmi/hrmi, masterData/approverprofile ต้องรู้ approval chain + leave context
slip/slip.jsmasterData/mapGroupDocumentsalary cert eligibility check
notification/notification.jsmasterData/referenceDatajoin dropdown data

ผล — testing ทั้ง 3 repo ต้อง mock controller ที่อยู่ข้างบน circular dependency ทำให้บางครั้ง Jest ต้องใช้ jest.mock() เพื่อ break cycle


เคล็ดลับตอนทำงานจริง

ถ้าจะเพิ่ม feature ใหม่ — เริ่มที่ไหน

  1. เขียน typeDefs ใน typeDefs/{domain}.js (หรือไฟล์ใหม่)
  2. เขียน resolver ใน resolvers/{domain}.js
  3. เขียน controller ใน lib/controllers/{domain}/
  4. เขียน repository ใน lib/repositories/{domain}/
  5. ถ้าต้องเรียก centralize → เขียน wrapper ใน centralize/controllers/{domain}.js

ห้ามข้ามชั้น — ถ้า resolver เรียก Models ตรง หรือ controller เรียก typeDefs จะเละ

ถ้า debug "function นี้ทำงานผิด"

เริ่มที่ typeDefs → resolver → controller → repository → Models/centralize ตามลำดับ อย่ากระโดดไปแก้ controller ก่อนเข้าใจ resolver เพราะบางที bug อยู่ที่ resolver เลือก controller ผิด ไม่ใช่ controller เอง

await arr.map(async ...) — bug ระบบ-wide

javascript
// ผิด — await ไม่ได้รอ Promise.all
await arr.map(async (item) => {
  await Models.X.create(...)
})

ที่ถูก — await Promise.all(arr.map(async (item) => {...})) หรือใช้ for...of ถ้าต้องการ sequential

พบใน document/documentDivision.js, career/careerDivision.js, course/courseDivision.js, post/postDivision.js, contactUs/*

Controller ที่ยาวเกินไป — แบ่งอย่างไร

withdraw/hospital.js (3,179 LOC) เป็น candidate สำหรับแบ่งเป็น:

  • hospital.create.js — create path (createWDHospitalv2 และ helper)
  • hospital.update.js — update + state machine (updateWDHospital, getActionStatus, validateUpdateStatus)
  • hospital.delegate.js — approval delegation (createDelegateApprover)
  • hospital.list.js — list/fetch (fetchListWDHospital)
  • hospital.report.js (มีแล้ว)
  • hospital.template.js (มีแล้ว)

แต่การแบ่งกระทบ caller ทุกที่ — ต้อง coordinate import update และทำใน branch แยกเพื่อให้ review ได้

ตอนแก้ bug ใน controller — checklist

  1. อ่าน resolver ก่อนเพื่อรู้ว่า controller ถูกเรียกด้วย argument อะไร
  2. ตรวจว่ามี transaction wrap หรือเปล่า — ถ้ามี ต้องดูว่าทุก query ใช้ { transaction: t } ครบไหม
  3. ตรวจว่ามี await arr.map ไหม — ถ้ามี ให้แก้เป็น Promise.all
  4. ตรวจว่า catch block อ้าง variable ที่ import จริงไหม (เช่น t, log)
  5. ตรวจ hardcoded path / magic constant / debug console.log
  6. ตรวจว่า controller นี้ถูก layer inversion เรียกกลับจาก repository ไหม (ตารางด้านบน)

ขั้นตอนถัดไป

ไป 3.1.7 REST surface (routes.js)