Skip to content

3 · Persistence — TypeORM + MSSQL (38 entities)

บทนี้อธิบายชั้นข้อมูลของ recruitment-api (branch: prod): connection ทั้ง 4 ชุด, entity ทั้งหมด 38 class (+ base.ts), ความสัมพันธ์, raw SQL / stored procedure บน DB ai, และจุดเสี่ยง SQL injection ที่พิสูจน์ได้จากโค้ด

Database topology

recruitment-api/src/app.module.ts (branch: prod):30-53 ลงทะเบียน TypeORM 4 connection; factory อยู่ที่ recruitment-api/config/configuration.ts (branch: prod):11-74

Connection nameConfig keyEnv prefixDatabase (env)autoLoadEntitiessynchronizeใช้จริงใน src/
maindbDB_*DB_NAME (เช่น recruitment)NODE_ENV === 'dev'NODE_ENV === 'dev'ใช่ — ทุก feature module
aidbAiDB_AI_*DB_AI_NAME (เช่น recruitment_ai)falsefalseใช่ — entityManagerAi.query(EXEC ...)
vtrcvtrcDBVTRC_DB_*VTRC_DB_NAMEfalsefalseไม่พบ @InjectEntityManager('vtrc')
workforceworkforceDBWORKFORCE_DB_*WORKFORCE_DB_NAMEfalsefalseไม่พบ @InjectEntityManager('workforce')

Driver / type มาจาก env (DB_TYPE ฯลฯ) — production ใช้ MSSQL ผ่าน package mssql (ดูบท 1 / package.json)

Extra options ร่วมกัน (configuration.ts:22-25,37-42,...):

typescript
extra: {
  trustServerCertificate: true,
  encrypt: false,
}

Connection ai เพิ่ม connectTimeout / requestTimeout = 30000 ms (configuration.ts:41-42)

Schema change management

  • Dev: synchronize: true เมื่อ NODE_ENV === 'dev' เท่านั้น
  • Prod: เปลี่ยน schema ผ่าน manual SQL ใต้ recruitment-api/sql/ (เช่น RC-*.sql, phase_2.3/, prod/, restructure/) — ไม่ใช้ TypeORM migration / Flyway

Logging: logging: ['error', 'query'] ทั้ง 4 connection — query log เปิดในทุก env ตาม config นี้ (ระวังข้อมูลส่วนบุคคลใน log)

Base entity

recruitment-api/src/entities/base.ts (branch: prod):3-27 — class BaseEntity (ไม่ใช่ @Entity) ให้ field ร่วม:

FieldTypeหมายเหตุ
idPK auto-increment@PrimaryGeneratedColumn
createdAt / updatedAtdatetimedefault CURRENT_TIMESTAMP
createdBy / updatedByvarchar(50)
createdByName / updatedByNamevarchar(500)
isDeletedbitsoft delete

เกือบทุกตาราง business extends BaseEntity ยกเว้นบางตัวที่ประกาศ PK/audit เอง (เช่น JobReqRestructureHistory, PermissionConfigEntity)

Entity inventory — 38 classes (39 ไฟล์รวม base.ts)

นับจาก recruitment-api/src/entities/ (branch: prod): 39 ไฟล์ = 38 @Entity class + base.ts (แก้เอกสารเก่าที่บอก 36)

กลุ่ม Job Request (11)

Entity classตารางหนึ่งบรรทัดไฟล์
JobRequestHdjobRequestHdหัวเอกสารคำขอเปิดรับ (documentNo RCM, status, isPublish, isQualified, ช่วงเปิดรับ)jobRequestHd.entity.ts
JobReqPositionjobReqPositionตำแหน่ง/อัตรา — manpowerNo, remainingManpower, org/position codesjobReqPosition.entity.ts
JobReqCapabilityjobReqCapabilityทักษะที่ต้องการjobReqCapability.entity.ts
JobReqCapabilityLangjobReqCapabilityLangทักษะภาษาที่ต้องการjobReqCapabilityLang.entity.ts
JobReqDegreejobReqDegreeคุณวุฒิที่ต้องการjobReqDegree.entity.ts
JobReqFieldjobReqFieldสาขาวิชาที่ต้องการjobReqField.entity.ts
JobReqProLicensejobReqProLicenseใบอนุญาตวิชาชีพที่ต้องการjobReqProLicense.entity.ts
JobReqHistoryjobReqHistoryประวัติเปลี่ยนสถานะคำขอjobReqHistory.entity.ts
JobReqAdvertisementjobReqAdvertisementเนื้อหาประกาศ (draft-js) + ไฟล์แนบโฆษณาjobReqAdvertisement.entity.ts
JobReqExtendHistoryjobReqExtendHistoryประวัติขยายเวลารับสมัครjobReqExtendHistory.entity.ts
JobReqRestructureHistoryjobReqRestructureHistoryประวัติปรับโครงสร้างตำแหน่งระหว่างทาง (ไม่ extend BaseEntity)jobReqRestructureHistory.entity.ts

ความสัมพันธ์หลัก (jobRequestHd.entity.ts:88-138):

JobRequestHd
  1:1  JobReqPosition
  1:1  JobReqAdvertisement
  1:N  JobReqDegree | JobReqField | JobReqCapability | JobReqCapabilityLang
       JobReqProLicense | JobReqHistory | JobReqExtendHistory | JobReqRestructureHistory
  1:N  RcAttachfile (via relatedId / polymorphic)

Cascade: เด็กส่วนใหญ่ cascade: true บน OneToMany/OneToOne ของ JobRequestHdsave(JobRequestHd) ใน create จึงพาลูกไปด้วย

กลุ่ม Candidate + AppProfile (11)

Entity classตารางหนึ่งบรรทัดไฟล์
CandidateHdcandidateHdใบสมัคร 1 ใบต่อตำแหน่ง (documentNo RCC, status, jobReqDocumentNo, client, appProfileId)candidateHd.entity.ts
CandidateHistorycandidateHistoryบันทึก remark/กิจกรรมต่อใบสมัครcandidateHistory.entity.ts
CandidateStatuscandidateStatusสถานะละเอียด + score/interviewScore/blacklist/declinecandidateStatus.entity.ts
AppProfileHdappProfileHdโปรไฟล์กลาง 1 คน (PII, email, empCode, profileKey, blacklist flags)appProfileHd.entity.ts
AppProfileEduappProfileEduประวัติการศึกษาappProfileEdu.entity.ts
AppProfileExpappProfileExpประสบการณ์ทำงาน (isCurrent สำคัญต่อ contract)appProfileExp.entity.ts
AppProfileCapabilityappProfileCapabilityทักษะของผู้สมัครappProfileCapability.entity.ts
AppProfileCapabilityLangappProfileCapabilityLangทักษะภาษาappProfileCapabilityLang.entity.ts
AppProfileProLicenseappProfileProLicenseใบอนุญาตวิชาชีพของผู้สมัครappProfileProLicense.entity.ts
AppProfileReferenceappProfileReferenceบุคคลอ้างอิงappProfileReference.entity.ts
AppProfilePersonalityappProfilePersonalityผล/บันทึกบุคลิกภาพappProfilePersonality.entity.ts

ความสัมพันธ์:

AppProfileHd 1:N (edu|exp|capability|capabilityLang|proLicense|reference|personality|candidateHd|rcAttachFile)
CandidateHd  N:1 AppProfileHd
CandidateHd  1:N CandidateHistory | CandidateStatus | RcAttachfile

Field สำคัญบน CandidateHd (candidateHd.entity.ts:17-99): documentNo, jobRequestNo, jobReqDocumentNo, status, email, client, manpowerNo, ชุด applyPosition*, org/bu/ward

กลุ่ม Qualified / Announcement (4)

Entity classตารางหนึ่งบรรทัดไฟล์
QualifiedHdqualifiedHdรอบประกาศผล (recordType exam/interview/passSelection, วันเวลา สถานที่, คำถาม/ข้อความอีเมล)qualifiedHd.entity.ts
QualifiedCandidatequalifiedCandidateผลต่อผู้สมัคร (score, interviewScore, isPass, candidateDocumentNo)qualifiedCandidate.entity.ts
QualifiedCandidateBackupqualifiedCandidateBackupสำรองรายชื่อ / snapshot ก่อน overwritequalifiedCandidateBackup.entity.ts
QualifiedHistoryqualifiedHistoryประวัติแก้ประกาศ + flag isSaveScorequalifiedHistory.entity.ts
QualifiedHd 1:N QualifiedCandidate | QualifiedCandidateBackup | QualifiedHistory | RcAttachfile

QualifiedCandidate เชื่อมใบสมัครด้วย candidateDocumentNo / candidateId เป็นค่าบนแถว (ไม่ใช่ TypeORM relation ไป CandidateHd โดยตรงใน entity)

กลุ่ม Contract (4)

Entity classตารางหนึ่งบรรทัดไฟล์
ContractHdcontractHdหัวสัญญา (status draft/confirm/..., exportToHRMiDate)contractHd.entity.ts
ContractEmploymentcontractEmploymentรายละเอียดจ้าง + รหัสภายใน + รหัส *HRMI ทั้งชุดcontractEmployment.entity.ts
ContractDtcontractDtรายละเอียดผู้รับจ้าง/เงื่อนไข (รวม candidateDocumentNo, empCode)contractDt.entity.ts
ContractHistorycontractHistoryประวัติ create / exportToHRMi / cancel / declinecontractHistory.entity.ts

Field HRMI บน ContractEmployment (contractEmployment.entity.ts:131-147):

  • orgUnitCodeHRMI
  • positionCareerCodeHRMI
  • positionManageCodeHRMI
  • positionTechnicalCodeHRMI
  • employmentTypeHRMI
  • employeeLevelCodeHRMI

นี่คือสะพานรหัสระหว่างระบบสรรหา กับระบบ HRMI ผ่าน mapping จาก workforce-api (ไม่ใช่ FK ข้าม DB)

กลุ่ม Utility / cross-cutting (8)

Entity classตารางหนึ่งบรรทัดไฟล์
AuthDataauthDataserver-side store ของ OTP / JWT / refresh ต่อ emailauthData.entity.ts
TbBlacklisttbBlacklistบัญชีดำ (ไฟล์ชื่อ backlist.entity.ts — typo)backlist.entity.ts
DeleteHistorydeleteHistoryคำขอลบข้อมูล PDPAdeleteHistory.entity.ts
EmailConfigEntityemailConfigผู้รับ/subject ต่อ emailType (export/cancel/decline ฯลฯ)emailConfig.entity.ts
MDropdownmDropdownmaster dropdown กลาง (dropdownType / dropdownCode)mDropdown.entity.ts
RcAttachfilercAttachFileไฟล์แนบ polymorphic หลาย parentrcAttachFile.entity.ts
PermissionConfigEntitypermissionConfigตั้งค่าสิทธิ์ menu (อ่านจาก blacklist module)permissionConfigEntity.entity.ts
CheckEmpRedCrossEntitycheckEmpRedCrosscache/ผลตรวจพนักงานสภากาชาดcheckRedcross.entity.ts

Polymorphic attachments (rcAttachFile)

recruitment-api/src/entities/rcAttachFile.entity.ts (branch: prod):11-38 มี @ManyToOne หลายตัวไปยัง parent คนละชนิด พร้อม FK column คนละชื่อ:

Join columnParent
relatedIdJobRequestHd
appProfileRelatedIdAppProfileHd
proLicenseRelatedIdAppProfileProLicense
qualifiedRelatedIdQualifiedHd
contractRelatedIdContractHd
candidateRelatedIdCandidateHd
jobReqAdsRelatedIdJobReqAdvertisement

แอปยังเก็บ relatedTable เป็น string (เช่น 'candidateHd', 'contractHd') ในตอน upload/create — ใช้บอก context ตอน pack ข้อมูลแม้ ORM จะมีหลาย JoinColumn

Field ไฟล์: fileName, fileType, fileSize, filePath, documentType, description, sort

ไฟล์จริงอยู่บน disk ภายใต้ /uploads/... (ดูบท 1) — ตารางเก็บ metadata + path เท่านั้น

Query patterns บน connection main

รูปแบบหลักใน repositories/services:

  1. TypeORM createQueryBuilder — ส่วนใหญ่ใช้ named parameters (:id, :email) เช่น candidate.repositories.ts:1300-1303
  2. entityManager.save / findOne / update — cascade ตาม relation
  3. Soft delete — set isDeleted = true (เช่น announcement delete) มากกว่า hard delete ยกเว้น authData ที่ delete จริงตอน logout/OTP rotate

ตัวอย่าง parameterized (ปลอดภัย):

typescript
.where('hd.id IN (:...id)', { id: arrWhere })
.andWhere('hd.isDeleted = 0')

จาก candidate.repositories.ts:1296-1304

ตัวอย่างที่ยังเป็น string ใน where ของ QueryBuilder (เสี่ยงถ้า input ไม่ถูก sanitize):

announcement.service.ts:1027-1037 ส่ง where เข้า generateRunningNo:

typescript
const whereCaseInterview = body.recordType === 'interview' ? ` and type = '${body.type}'` : '';
documentNo: await this.running.generateRunningNo(
  QualifiedHd, 'documentNo', '0001', prefix, 't',
  `isDeleted = 0 and recordType = '${body.recordType}' ${whereCaseInterview}`,
),

running.util.ts:26 แปะเป็น .where(`${tableAlias}.${whereClause}`)body.recordType / body.type จาก request ถูก interpolate ตรง ๆ → SQL injection surface ที่ยืนยันได้ (harden โดย whitelist enum หรือ named parameters)

Connection ai — stored procedures + injection points

Inject: @InjectEntityManager('ai') ใน

  • candidate.repositories.ts:55
  • jobList.repositories.ts:28
  • appProfile.repositories.ts:34

จุดเรียก entityManagerAi.query ทั้งระบบ (grep ทั้ง src/):

1) Candidate compare by AI

recruitment-api/src/modules/candidate/candidate.repositories.ts (branch: prod):1324-1338

typescript
let param = '@Input_Job_ID = ' + query.jobId + ', ';
for (let i = 0; i < 5; i++) {
  let id = multipleId[i] ? `'${multipleId[i]}'` : null;
  param = param.concat('@Input_Candidate_ID' + (i + 1) + ' = ' + `${id}` + ...);
}
const querySelect = `EXEC Module3_3_Get_Selected_Candidate_Per_Job ${param}`;
await this.entityManagerAi.query(querySelect);

jobId และ candidate ids ถูกต่อเป็น string — ไม่ใช่ parameterized RPC → SQL injection risk หาก query string มาจาก client โดยตรง (GetCandidateCompareByAIDto)

2) Job recommend by AI

recruitment-api/src/modules/jobList/jobList.repositories.ts (branch: prod):700-703

typescript
const querySelect = `EXEC dbo.Module3_4_Get_All_Job_Per_Candidate @Input_Candidate_ID='${param.id}'`;
await this.entityManagerAi.query(querySelect);

param.id มาจาก path :id — มีการเช็ค ownership ที่ service ชั้นบน (jobList.service.ts:175-185) แต่ SQL ยังเป็น string interpolation

3) Candidate list per job by AI

recruitment-api/src/modules/appProfile/appProfile.repositories.ts (branch: prod):335-338

typescript
const querySelect = `EXEC dbo.Module3_2_Get_All_Candidate_Per_Job @Input_Job_ID='${param.id.toString()}'`;
await this.entityManagerAi.query(querySelect);

สรุป severity (จากหลักฐานโค้ด)

จุดParameterized?หมายเหตุ
TypeORM QB ส่วนใหญ่บน mainใช่ (:param)มาตรฐานปลอดภัยกว่า
EXEC ... ${...} บน aiไม่ยืนยัน string concat — ต้อง sanitize หรือใช้ proper parameter binding ของ mssql
generateRunningNo where จาก announcementสงสัยinterpolate recordType/type

ไม่พบ raw SQL ข้าม schema ไป vtrc/workforce ใน Nest source — การดึงข้อมูลภายนอกทำผ่าน HTTP axios แทน

Unused DB connections

แม้ vtrc และ workforce จะถูกประกาศใน AppModule และมี credential ใน .env:

  • ไม่พบ @InjectEntityManager('vtrc') หรือ 'workforce' ใน tree ที่สำรวจ
  • การคุยกับ VTRC / workforce ทำผ่าน HTTP (VTRC_SERVER, WORKFORCE_SERVER) ไม่ผ่าน connection เหล่านี้

ถือเป็น dead config / debt — อาจเคยวางแผน read-only join แล้วเปลี่ยนไปใช้ API

Manual SQL folder layout

recruitment-api/sql/ (branch: prod) อย่างน้อยมี:

  • RC-20260216-01.sql, RC-20260318-01.sql — ticket-based patches
  • phase_2.3/ — phase scripts
  • prod/ — production historical scripts
  • restructure/ — งานปรับโครงสร้างตำแหน่ง

อย่าสมมติว่า TypeORM entity ตรงกับ prod 100% โดยไม่เทียบสคริปต์ล่าสุด — โดยเฉพาะ field ที่เพิ่มหลัง synchronize ถูกปิด

Relationship map (ย่อ)

jobRequestHd ──1:1── jobReqPosition (remainingManpower)

     ├── published ──► jobList reads (no separate jobList table)

     └── referenced by candidateHd.jobReqDocumentNo (string, not ORM FK)

appProfileHd ──1:N── candidateHd
candidateHd ──1:N── candidateStatus / candidateHistory
candidateHd.documentNo ──► contractDt.candidateDocumentNo (string)
qualifiedCandidate.candidateId ──► candidateHd.id (app-level)

contractHd ──1:1── contractEmployment (*HRMI codes)
         ──1:1── contractDt
         ──1:N── contractHistory

authData.email ──► portal session (OTP/JWT) — ไม่ใช่ FK ไป appProfile
tbBlacklist.idCard ──► match กับ appProfile / import Excel

Soft delete และ hard delete

พื้นที่พฤติกรรม
Job request / candidate / announcement / contract / appProfilesoft (isDeleted)
authDatahard delete ตอน OTP ใหม่ / logout (auth.service.ts:110-127)
ไฟล์ diskremoveFileFromPath แยกจาก soft delete แถว DB

Running number persistence

recruitment-api/libs/utils/running.util.ts (branch: prod):8-55 อ่านค่าล่าสุดจากตาราง entity เอง (ไม่ใช่ sequence ของ MSSQL):

typescript
.select(columnName)
.where(`${tableAlias}.${whereClause}`)
.andWhere(`${columnName} LIKE :prefix`, { prefix: `${currentPrefix}-%` })
.orderBy(`CAST(SUBSTRING(...) AS INT)`, 'DESC')
.take(1);
  • Default whereClause = 'isDeleted = 0' → กลายเป็น t.isDeleted = 0 (ถูกต้อง)
  • Announcement ส่ง where ยาว: `isDeleted = 0 and recordType = '{recordType}' ...` → กลายเป็น t.isDeleted = 0 and recordType = '...' (prefix t. ติดเฉพาะต้นสตริง)
  • LIKE :prefix ใช้ named parameter — ส่วนนี้ปลอดภัย
  • ส่วนที่ interpolate คือ whereClause / andWhere ที่ caller ส่งเข้ามา

รูปแบบเลขที่: {LETTER}{YY}{MM}-{NNNN} เช่น RCM2607-0001, RCC2607-0003, WTT2607-0002

Entity field deep-dives (ตารางที่โหลดบ่อย)

authData — session store

recruitment-api/src/entities/authData.entity.ts (branch: prod):4-16

ColumnLengthใช้เก็บ
email255key หา session
otp10OTP ระหว่าง login
token4000access JWT ล่าสุด
refreshToken4000refresh JWT ล่าสุด

ไม่มี unique constraint ใน entity definition — uniqueness พึ่ง logic delete ก่อน create ใน jobList.service.sendOtp

emailConfig — routing อีเมล HRMI / announcement

recruitment-api/src/entities/emailConfig.entity.ts (branch: prod):3-30

Columnหน้าที่
emailTypeตรงกับ status export (export / cancel / decline ฯลฯ)
emailTo / emailCcรายชื่อผู้รับ
subjectsubject template
isActive / isDeletedเปิด-ปิดแถว

contract.service.ts:1677-1679 โหลดด้วย findOne({ emailType: param.status, isActive: true, isDeleted: false })

deleteHistory — PDPA

recruitment-api/src/entities/deleteHistory.entity.ts (branch: prod):4-47 — ไม่ extend BaseEntity; มี appProfileId FK ไป AppProfileHd, สถานะ delAccStatus, เหตุผล/หมายเหตุ, เวลา approve/cancel

contractEmployment — รหัสคู่ขนาน

นอกจาก field องค์กร/ตำแหน่งภายใน ยังมี:

  • manpowerNo, salary, promoteType, sourceDb, guarantor, bank / bankBranch, adjustmentType
  • ชุด *HRMI 6 ฟิลด์ (ดูตารางกลุ่ม Contract ด้านบน)

ค่า *HRMI ถูกเขียนตอน create/update contract เท่านั้น — ไม่ใช่ generated column ของ DB

jobReqPosition — โควต้า

Field หลักที่ pipeline พึ่ง:

  • manpowerNo — เลขอัตรา (อาจ comma-separated หลายเลข)
  • remainingManpower — ลดตอน hired / เพิ่มตอน decline
  • ชุดรหัสตำแหน่ง career / manage / technical / org / bu / ward / employment type / level

ไม่มี unique constraint ใน entity ที่บังคับ manpowerNo ต่อ job request — validation อยู่ที่ service validateData

Publish filter ที่ jobList อ่านจาก main

jobList.repositories.ts (ตัวอย่าง :716-723) กรองประกาศสาธารณะด้วยเงื่อนไขประมาณ:

  • jobRequestHd.isPublish = 1
  • jobReqPosition.remainingManpower != 0
  • jobRequestHd.status != 'draft'
  • วันที่ปัจจุบันอยู่ในช่วง startDateendDate หรือ isNoEndDate = 1

ไม่มีตาราง jobList แยก — เป็น read model จาก jobReqPositionjobRequestHd

InjectEntityManager map (ใครถือ connection อะไร)

Filemainai
candidate.repositories.tsใช่ใช่
candidate.service.tsใช่ไม่
jobList.repositories.tsใช่ใช่
appProfile.repositories.tsใช่ใช่
jobRequest.* / announcement.* / contract.* / auth.* / blacklist.* / deleteHistory.*ใช่ไม่

ทุก write path ของ business เอกสารอยู่บน mainai เป็น read-only SP สำหรับคะแนนแนะนำ

N+1 และ query shape ที่พบบ่อย

Announcement getById (announcement.service.ts:382-448) วน qualifiedCandidate แล้ว:

  1. getAllProfile(allEmail) ครั้งเดียว (ดี)
  2. ต่อแถว: candidateRepo.getByDocumentNoN+1 ต่อผู้สมัคร
  3. ถ้าไม่ใช่ exam: QueryBuilder ดึง score จากรอบ exam/interview อีกครั้งต่อแถว

Contract getByCandidateId ดึง candidate → contract → job request → filter manpower ที่ถูกใช้แล้วจาก getContractManpowerNo — หลาย round-trip แต่ไม่ใช่ N+1 loop

เมื่อ profile performance ของหน้าประกาศผล ให้เริ่มที่ loop ใน getById ก่อน

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

  • นับ entity ให้ถูก: 38 class + base — เอกสารเก่าที่บอก 36 ผิด
  • อย่าพึ่ง connection ชื่อ vtrc/workforce ใน TypeORM จนกว่าจะมี inject จริง — ใช้ HTTP แทน
  • ก่อนเพิ่ม column production ให้เพิ่มสคริปต์ใน sql/ และอัปเดต entity ให้ตรงกัน (synchronize ปิดใน prod)
  • AI SP ทั้ง 3 จุดควรแก้เป็น parameterized ในรอบ security harden
  • เวลา join ใบสมัครกับประกาศผล ใช้ candidateDocumentNo / jobReqDocumentNo เป็น business key ไม่ใช่เฉพาะ PK
  • อย่าสร้าง MSSQL SEQUENCE ใหม่โดยไม่ไล่ RunningUtil — ระบบพึ่ง max+1 จากตาราง
  • ไฟล์ entity ชื่อ backlist.entity.ts แต่ตารางคือ tbBlacklist — import ผิดชื่อไฟล์ง่ายเมื่อค้นหา

ไป บท 4 Auth + integrations