Skip to content

3.2.6 · TypeDefs + resolvers catalog

บทนี้ catalog GraphQL schema (32 SDL files, 2,629 LOC) และ resolvers (28 files, 1,826 LOC) เพื่อให้ navigate codebase ได้ พร้อมชี้ patterns และ inconsistencies ที่สำคัญ


ขนาดรวม

ส่วนLOCไฟล์
TypeDefs (SDL)2,62932
Resolvers1,82628
รวม schema layer~4,50060

เล็กกว่า vtrc-api (queries.js ไฟล์เดียว 4,500 LOC) — แต่กระจายไฟล์ ทำให้ navigate ง่ายกว่า


TypeDefs catalog (32 ไฟล์)

ไฟล์LOC@auth ใช้?หัวข้อหลัก
root.js35declare directiveQuery, Mutation, Subscribtion (typo), SortingBy, YesNo, JSON, Date
authentication.js56pwdVerification @authAuthResponse, AuthProfileResponse, AuthDomains, AppCredentials
personal.js116personalProfile @auth, employeeProfileHistory @authPersonalProfile, EmployeeDetail, ProfileHistory
employee.js2713 fields @authEmployeeProfile, WorkingProfile, EmployeeFinancial, AccordingProfile, EmployeeAddress, MeisDivision, EmplGroup, EmpLevel, PositionManage/Technical, ImageContent
family.js58employeeFamily @authEmployeeFamily, FamilyPersonInfo, FamilyRelationship enum
organization.js97noneOrganization, OrganizationUnit, OrgazationAddress (typo)
userVerification.js102nonerequestVerificationCode, profileVerificationChecker, profileVerification
registration.js126changePassword @authcreateAccountByVerifyCode, changePasswordByVerifyCode, requestOtp, createSSONotifyUser mutations
timeAttendance.js33timeAttendanceByDate @authTimeAttendance, TimeAttendanceResponse
payrollSlip.js862 fields @authpayrollSlipDetail, payrollSlipDetail6Month
payrollSlip_removed.js112DEAD FILE — ไม่ถูก import
finance.js26employeeFinanceType @authEmployeeFinanace (typo), EmployeeBookBank
bank.js40noneBank, BankBranch, BankBook
tax.js106noneEmployeeTax (90+ fields, full Thai tax form)
fund.js86commented #@authProvidentFund, FundPolicy
compensation.js52employeeCompensation @authEmployeeCompensation, EmployeeCompensationIncomeDeduct
referenceData.js36nonerefOrganizations, refOrganizationBelongs, refNewOrganizationUnits
masterData.js86nonemasterOrgSystem, masterPlace
searchProfile.js44searchUser @auth(accessLevel: 4, ignoreAuth: true)SearchUserResponse
hrEnum.js25nonehrEnum query
notification.js25nonesendSMSNotification mutation
hrmi.js394nonelargest SDL — leave/medical/approver domain
childTuition.js88nonefetchListChildTuitions, fetchChildTuitionDetail
salaryCert.js59nonesalaryCertDetail, salaryCert
hrPerProbation.js15nonecheckHrPerProbationNotApprove
twoWay.js65none6 queries for 2-way sync
sso.js22nonefetchNonEmpSSO, nonEmpSSO
studyLeaveRequest.js105nonegetEmpStudyLeaveRequestCD, getPersonalCD
statLog.js26nonestatLog, statLogList
hr/hrTaxes.js113hrTaxes @authHrTaxesAttr input (~80 fields)
hr/hrAddress.js39hrAddress @authHrAddressAttr input
hr/hrPhoneNumber.js23hrPhoneNumber @authHrPhoneNumberAttr input

hrmi.js — SDL ใหญ่สุด (394 LOC)

ไฟล์เดียวที่ cover leave + medical + approver domain — port มาจาก HRMI leave-management system เก่า

หัวข้อในไฟล์

hrmi.js (394 LOC)
├── Leave
│   ├── getLeaveType
│   ├── getLeaveDocumentList
│   ├── getLeaveDocumentDetail
│   ├── getLeaveDay / getLeaveDayAll
│   ├── getWorkingDay
│   ├── getLeaveShiftTime / getShiftTime
│   ├── createLeaveDocument
│   ├── editLeaveDocument
│   └── deleteLeaveDocument
├── Medical
│   └── getMedical
├── Approver
│   ├── getApprover
│   └── getLeaveApprover
├── Attachment
│   ├── createAttachmentFile
│   ├── updateAttachmentFile
│   └── deleteAttachmentFile
├── Org queries
│   ├── fetchEmpWithOrgnize
│   ├── fetchEmpwithCoA
│   └── employeeDetailProfile
└── enums
    ├── ApproveStatus
    ├── DurationType
    └── ApproverType
  extend type Query {
    getMedical(
      empCode: String!
    ): [MedicalExpensesLicensee]

    getApprover (
      docType: ApproverType!
      empCode: String!
    ): [Approver]

    getLeaveApprover (
      docType: ApproverType!
      empCode: String!
      leaveTypeId: ID!
      leaveDay: Float!
    ): [Approver]

    getLeaveType(
      empCode: String
      isReqByEmp: Boolean!
    ): [LeaveTypeData]

    getLeaveDocumentList(
      empCode: String!
      leaveStartDate: Date!
      leaveEndDate: Date!
      leaveTypeId: ID
      approveStatus: ApproveStatus!
    ): [LeaveDocumentListData]

    // ... 24 queries total

แม้ว่า SDL เขียน empCode เป็น parameter แต่ resolver ภายในจะ lookup sourceDb ผ่าน getEmpIdentyCard(empCode) แล้ว pass ไปที่ lib — caller จึงไม่ต้องส่ง sourceDb โดยตรงใน hrmi.js queries (ต่างจาก SDL signature เก่าที่เคยมี sourceDb parameter)


Resolvers catalog (28 ไฟล์)

ไฟล์LOCResolvers หลัก
main.js14Query.__type / __schema → throw (introspection guard)
authentication.js72pwdVerification, signInDomains (hardcoded 26-row org list)
personal.js79personalProfile, employeeDetail, employeeProfileHistory
employee.js127employeeProfile, accordingProfile, employeeFinancial, employeeManagement
organization.js42refOrganizations, refOrganizationBelongs
userVerification.js74requestVerificationCode, profileVerificationChecker, profileVerification
registration.js386isAccountExists, createAccountByVerifyCode, changePassword, requestOtp, createSSONotifyUser
timeAttendance.js33timeAttendanceByDate
payrollSlip.js240payrollSlipDetail, payrollSlipDetail6Month
finanace.js26employeeFinanceType (typo)
compensation.js29employeeCompensation
fund.js23fetchEmployeeProvidentFund, createChangeBenefitPerson
masterData.js31masterOrgSystem, masterOrgSystemCreate, masterOrgSystemUpdate
searchProfile.js23searchUser (apiKey-only ผ่าน ignoreAuth)
hrEnum.js17hrEnum
notification.js9sendSMSNotification
hrmi.js24424 resolvers — leave/medical/approver/attachment
bank/book.js38bankBookEmployee, bankBookEmployeeArray
childTuition.js204 child tuition queries
salaryCert.js12salaryCertDetail
hrPerProbation.js12checkHrPerProbationNotApprove
twoWay.js376 queries for 2-way sync
sso.js12fetchNonEmpSSO
studyLeaveRequest.js18getEmpStudyLeaveRequestCD, getPersonalCD
statLog.js12statLog
hr/hrTaxes.js33hrTaxes mutation (wraps dbHrTemp.transaction())
hr/hrAddress.js54hrAddress mutation (OTP ถูกปิด)
hr/hrPhoneNumber.js52hrPhoneNumber mutation (OTP ยังทำงาน)

OTP inconsistency (Critical)

hr/hrAddress.js — OTP ปิดไปตั้งแต่ 8 Jul 2021

export default {
  Mutation: {
    hrAddress: async (obj, { address, otp }, context, info) => {
      const { identityCard } = context.userData
      const profileKey = address.profileKey
      const matched = await personAndEmployeeIsMatch(identityCard, profileKey)
      if (matched !== null) {
        /*
        // ## REMOVE OTP 08Jul2021 ##

        // Check OTP is correct
        const checkOtpIsCorrect = await checkOtp(otp.otpToken, otp.otpToken, otp.refCode)
        if (!checkOtpIsCorrect) {
          responseError('', {}, 'OTP_INCORRECT')
        }
        const t = await Models.db.transaction()
        const result = await removeOtp(t, otp.otpToken)
        if (!result) {
          // Remove OTP error
          t.rollback()
          log.error(log.logTextFormat('REMOVE_OTP_FAILED', 'mutation:hrAddress', 'ERROR', 'Remove OTP failed (see more at error log).', { profileKey, otpToken: otp.otpToken }))
          responseError('', {}, 'INTERNAL_SERVER_ERROR')
        }
        */

        address.identityCard = identityCard
        address.empId = matched.empId
        address.sourceDb = matched.sourceDb
        const t2 = await Models.dbHrTemp.transaction()
        const saved = await saveAddressData(t2, address)
        // ...

🔴 High (CORR-CU-02) — address mutation (ที่ include email + phone fields ตาม HrAddressAttr input) ไม่ต้องผ่าน OTP

hr/hrPhoneNumber.js — OTP ยังทำงาน

export default {
  Mutation: {
    hrPhoneNumber: async (obj, { phoneNumber, otp }, context, info) => {
      const { identityCard } = context.userData
      const profileKey = phoneNumber.profileKey
      const matched = await personAndEmployeeIsMatch(identityCard, profileKey)
      if (matched !== null) {
        // Check OTP is correct
        const checkOtpIsCorrect = await checkOtp(otp.otpToken, otp.otpCode, otp.refCode)
        if (!checkOtpIsCorrect) {
          responseError('', {}, 'OTP_INCORRECT')
        }
        const t = await Models.db.transaction()
        const result = await removeOtp(t, otp.otpToken)
        if (!result) {
          t.rollback()
          log.error(log.logTextFormat('REMOVE_OTP_FAILED', 'mutation:hrPhoneNumber', 'ERROR', 'Remove OTP failed (see more at error log).', { profileKey, otpToken: otp.otpToken }))
          responseError('', {}, 'INTERNAL_SERVER_ERROR')
        }

        phoneNumber.identityCard = identityCard
        phoneNumber.empId = matched.empId
        phoneNumber.sourceDb = matched.sourceDb
        // ...

Inconsistency

sibling resolvers 2 ตัว (address + phone) ใช้ pattern ต่างกัน — address แก้ได้ไม่ต้อง OTP แต่ phone ต้อง ควรมี single source of truth สำหรับ "field ไหนบ้างที่ต้อง OTP"


signInDomains — hardcoded org list

authentication.js resolver signInDomains คืน list ของ org ที่ login ได้ โดย hardcoded 26 rows ใน code

const getOrgUnitLevel1 = () => {
  return [
    { id: '3F3BF3AD-B4C9-4D44-A56F-AB55C4E4FB01-00_d6873c51-63e0-44a8-9dbd-ccc7d28d2983', value: '...', prefix: '@', textTh: 'กลุ่มงานกลยุทธ์องค์กร', textEn: 'กลุ่มงานกลยุทธ์องค์กร' },
    { id: '3F3BF3AD-...A987969A-...', value: '...', prefix: '@', textTh: 'มูลนิธิสงเคราะห์เด็กของสภากาชาดไทย', textEn: 'Thai Red Cross Children Home' },
    { id: '3F3BF3AD-...CU-RE-STRUCTURE-ID-02', value: '...', prefix: '@', textTh: 'โรงพยาบาลจุฬาลงกรณ์', textEn: 'Chulalongkorn Hospital' },
    // ... 23 more
    { id: '3F3BF3AD-...9625B335-...', value: '...', prefix: '@', textTh: 'สำนักงานอาสากาชาด', textEn: 'Red Cross Volunteer Bureau' }
  ]
}

export default {
  Query: {
    signInDomains: async (obj, args, context, info) => {
      return getOrgUnitLevel1()

      // Return Organization DO NOT RETURN SOURCE_DB.
      const orgList = await getOrganizations({ order: [['OrgName', 'ASC']] })   // ← dead code หลัง return
      // ...
    }
  }
}

🔴 High (QUAL-CU-03) — org list ไม่ได้มาจาก DB ทำให้เพิ่ม/แก้ org ต้อง deploy code ใหม่ ควรย้ายไป masterData table

สังเกต — code หลัง return getOrgUnitLevel1() (บรรทัดที่ 56 เป็นต้นไป) เป็น dead code ที่จะไม่ execute เพราะ return ก่อน


registration.js — ไฟล์ใหญ่สุด (386 LOC)

cover account creation + password management flow

registration.js (386 LOC)
├── isAccountExists         (check ก่อน create)
├── createAccountByVerifyCode
├── changePasswordByVerifyCode
├── changePassword
├── requestOtp
├── getVerifyCodeByEmail
└── createSSONotifyUser

flow ทั้งหมดผูกกับ SSO + AD + SMS gateway — เป็นไฟล์ที่ซับซ้อนที่สุดใน resolver layer

changeUserPassword helper

const changeUserPassword = async (username, newPassword) => {
  const result = { changed: false, error: null }
  // 1. Search user from AD for dn
  const found = await searchUserFromAd(username)
  if (found !== null) {
    if (found.count === 1) {
      const profile = found.profile[0]
      const dn = profile.dn
      // 2. Call update password in AD
      const update = await adChangeEntryValue(dn, {
        unicodePwd: Buffer.from(`"${newPassword}"`, 'utf16le').toString()
      }, 'replace')
      // ...

AD password update — password ถูก wrap ใน double-quote แล้ว encode เป็n UTF-16LE ตาม requirement ของ AD unicodePwd attribute


payrollSlip.js — 240 LOC

resolver ที่คำนวณ payroll slip detail

export default {
  PayrollSlip: {
    bookNo(parent) {
      return !isEmpty(parent.bookNo) ? sensorBookNo(parent.bookNo) : ''
    }
  },
  Query: {
    payrollSlipDetail: async (obj, { profileKey, year, month }, context, info) => {
      const d = makeDate()
      if (month < 1 || month > 12) {
        responseError('', { month }, 'MONTH_INVALID')
      }
      if (year > +d.yyyy) {
        responseError('', { month }, 'YEAR_INVALID')
      }

      const { userData } = context
      const profile = await personAndEmployeeIsMatch(userData.identityCard, profileKey)
      if (profile !== null) {
        const empId = profile.empId
        const sourceDb = profile.sourceDb

        let payrollSlip = await getPayrollSlipDetail(empId, year, month, sourceDb)
        // ...

        // If is CU_SC then remove KCMH from division
        if (workingProfile.sourceDb === 'dbHRMI_CU_SC_rep') {
          if (typeof divisions[0] !== 'undefined') {
            if (divisions[0].id === 'CU-RE-STRUCTURE-ID-02') {
              // Remove first array index
              divisions.splice(0, 1)
            }
          }
        }

ดึงข้อมูลจาก DB view uvi3PaySlipHD + uvi3PaySlipDT (models/hrmi/salary/) — เป็น view ที่ MSSQL side define ไว้

Special-case dbHRMI_CU_SC_rep

มี special-case logic สำหรับ shard dbHRMI_CU_SC_rep — ลบ KCMH division ออกจากผลลัพธ์ เป็น business rule เฉพาะที่บ่งบอกว่ามี org structure แตกต่างกันในแต่ละ shard


hrmi.js resolver — 244 LOC

export default {
    Query: {
        getMedical: async (root, { empCode }, context, info) => {
            const empIdenty = await hrmiMedical.getEmpIdentyCard(empCode)
            if (empIdenty.length > 0) {
                const orgCode = empIdenty[0].orgCode
                const identityCard = empIdenty[0].identityCard
                return await hrmiMedical.getMedical(identityCard, orgCode, empCode)
            } else {
                responseError('', { error: 'Employee not found.' }, 'PROFILE_NOT_FOUND')
            }
        },
        getApprover: async (root, { docType, empCode }, context, info) => {
            const empIdenty = await hrmiMedical.getEmpIdentyCard(empCode)
            // ...

pattern ที่ซ้ำในทุก query — getEmpIdentyCard(empCode) เพื่อ resolve identityCard + orgCode (ข้าม shard lookup) แล้ว pass ไป lib

มี debug print หลายจุดที่ลืมลบ (console.log("empIdenty ->", empIdenty) ที่บรรทัด 45, 76, 94, ...)


hrTaxes resolver — transaction pattern

    hrTaxes: async (obj, { taxes }, context, info) => {
      const { identityCard } = context.userData
      const profileKey = taxes.profileKey
      const matched = await personAndEmployeeIsMatch(identityCard, profileKey)
      if (matched !== null) {
        taxes.identityCard = identityCard
        taxes.empId = matched.empId
        taxes.sourceDb = matched.sourceDb
        const t = await Models.dbHrTemp.transaction()
        const saved = await saveTaxesData(t, taxes)
        if (saved) {
          t.commit()
          return saved
        } else {
          t.rollback()
          responseError('', {}, 'INTERNAL_SERVER_ERROR')
        }
      } else {
        // Profile doesn't match
        responseError('', { profileKey }, 'EMPLOYEE_NOT_FOUND')
      }
    }

pattern mutation ที่ใช้ transaction — t.commit() สำเร็จ หรือ t.rollback() ถ้า fail เป็น pattern เดียวกับ hrAddress และ hrPhoneNumber


userVerification.js — flow 3 ขั้น

    requestVerificationCode: async (obj, { requestFor, sendMethod, citizenId, employeeCode, birthDate, org }, context, info) => {
      const orgFormat = formatOrgId(org)
      org = orgFormat.org
      const orgUnit = orgFormat.orgUnit

      const profiles = await getEmployeeByEmpCode(employeeCode)
      const isProfileAllowed = await checkEmployeeAbleLogin(profiles)
      if (isProfileAllowed) {
        const workProfileSourceDb = profiles[0].workingProfile.sourceDb
        const verified = await userVerification(null, citizenId, employeeCode, birthDate, workProfileSourceDb, true)
        if (verified) {
          citizenId = cleansingIdentityCardNumber(citizenId)
          const workflow = await getAccountWorkflow(citizenId, employeeCode)
          if (requestFor === 'CREATE_ACCOUNT' && workflow === 'A') {
            responseError('', {}, 'OPERATION_NOT_ALLOWED')
          }
          return sendVerifyCode(sendMethod, citizenId, employeeCode)
        }
      }
    },

flow ที่ซับซ้อน — formatOrgIdgetEmployeeByEmpCodecheckEmployeeAbleLoginuserVerificationgetAccountWorkflowsendVerifyCode เป็น 6 ขั้นที่ซ้อนกัน


Patterns ที่สังเกตได้

1. Resolver ไม่ได้แยกตาม bounded context

ไฟล์ resolver ผสมหลาย domain — hrmi.js เดียว cover leave + medical + approver + attachment + org queries (244 LOC) ไม่ได้แยกเป็น leave.js, medical.js, approver.js ตาม domain

ใน Go target จะแยกเป็น bounded context ชัดเจน

2. ไม่มี DataLoader pattern

ทุก query ที่ resolve nested field ทำ N+1 — เช่น Employee ที่มี workingProfile association ถ้า query 100 employee พร้อมกัน จะมี 100+1 query

ใน Go target ใช้ dataloader pattern หรือ join โดยตรง

3. sourceDb กระจายทั้ง schema

parameter sourceDb อยู่ในเกือบทุก query ของ hrmi.js (เก่า) — caller (vtrc-api) ต้องรู้ว่าต้อง pass อะไร

ในเวอร์ชันปัจจุบัน resolver เก็บ logic การ lookup sourceDb ภายใน ผ่าน getEmpIdentyCard(empCode) — caller ส่งแค่ empCode

4. Debug print ลืมลบ

หลาย resolver มี console.log("xxx ->", xxx) ที่ลืมลบ — เป็น evidence ว่าไม่มี lint rule ที่ catch

5. Hardcoded data ใน resolver

signInDomains hardcoded 26 rows, getOrgUnitLevel1 hardcoded org unit list — ควรย้ายไป DB


Dead SDL — payrollSlip_removed.js

112 LOC ที่ไม่ถูก import — เป็นเวอร์ชันเก่าที่ใช้ empCode แทน profileKey

payrollSlipInfo(empCode: String, year: Int, month: Int): Payroll

live version (payrollSlip.js) ใช้ profileKey แทน

payrollSlipDetail(profileKey: String, ...): PayrollSlipDetail

migration จาก empCodeprofileKey เกิดขึ้นเพื่อ support multi-tenant (เพราะ empCode ไม่ unique ข้าม shard)


สรุป

PatternสถานะSeverity
Schema sizeเล็กกว่า vtrc-api🟢 Good
File organizationผสม domain ในไฟล์เดียว (hrmi.js)🟡 High
@auth coverageไม่ครบทุก field — หลาย field public🟡 High
OTP consistencyhrAddress vs hrPhoneNumber ต่างกัน🔴 Critical
Hardcoded datasignInDomains 26 rows🟡 High
Dead SDLpayrollSlip_removed.js🟢 Medium
N+1 patternsไม่มี DataLoader🟡 High
Typos in schemaEmployeeFinanace, OrgazationAddress, Subscribtion🟢 Medium
Debug prints ลืมลบหลายจุดใน hrmi.js resolver🟢 Medium
Pattern miss HrTimeAttendanceไม่มี scope constraint🔴 Critical

อ่านต่อ → 3.2.7 External integrations