Skip to content

2 · Core pipeline — chat-center-api

บทนี้ trace ทุก pipeline หลักของ service: HTTP/REST, Socket.IO, LINE webhook, และ cron — ทั้งสี่ใช้ data store เดียวกันแต่ authentication คนละแบบ

อ้างอิง branch: prod


Layer ร่วมของ HTTP request

ทุก HTTP request ผ่านลำดับใน chat-center-api/src/main.ts (branch: prod):15-45:

text
HTTP request


[NestExpressApplication]
    │  useStaticAssets(uploads/) prefix /chat-center-api/

bodyParser.json / urlencoded (limit 50mb)


HttpExceptionFilter (global)


JwtAuthGuard (global)
    │  ถ้า @Public() → skip
    │  ไม่มี → JwtStrategy.verify(Bearer token)

ValidationPipe (whitelist + forbidNonWhitelisted)


Controller → Service → EntityManager / Mongoose

CORS เปิด origin: '*' + credentials: true (main.ts:36-41)

สิ่งที่ไม่มี — ไม่มี role/menu decorator ระดับ HTTP แบบ vtrc-api (@auth(accessRole:...)) การกรองสิทธิ์ทำใน service เท่านั้น

JWT payload shape

JwtStrategy.validate คืน payload ทั้งก้อนเป็น req.user (chat-center-api/libs/jwt/jwt.strategy.ts (branch: prod):18-22) — shape ตาม UserType:

typescript
export interface UserType {
  id: string;
  uid: string;
  profile: ProfileType;  // empCode, fullName, sourceDB, ...
  iat: number;
  exp: number;
  iss: string;
  jti: string;
}

หลักฐาน — chat-center-api/src/dto/user.dto.ts (branch: prod):1-9

Secret อ่านจาก configService.get('jwtSecretKey') ← env SECRET_KEY (configuration.ts:5, jwt.strategy.ts:14)


Pipeline A — รายการห้องแชท (GET /chat/rooms)

Flow ที่กระทบ SQL injection surface มากที่สุด และแสดง authorization แบบ business-logic

text
2way-meeting-backoffice
   │  Authorization: Bearer <jwt>

ChatController.rooms(user, query: FilterRoomDTO)
   │  chat.controller.ts:18-21

ChatService.rooms(user, query)
   │  chat.service.ts:93-206
   ├─ findOne(TeamMemberEntity) + relations teamAdmin.tags
   ├─ ต่อ raw SQL string ตาม filter (fullName, diviCode, status, tagsId)
   ├─ ถ้า teamAdmin.level !== 1 → จำกัดด้วย tagsId / noteTag ของสมาชิก
   ├─ entityManager.query(queryString)   ← ไม่มี parameter binding
   └─ ต่อห้อง: resolve TagsEntity, status ล่าสุด, last message จาก Mongo Conversation

Step ละเอียด

  1. หาสมาชิกทีมของ empCode จาก JWT:
typescript
const data = await this.entityManager.findOne(TeamMemberEntity, {
  relations: { teamAdmin: { tags: true } },
  where: { empCode: empCode },
});

หลักฐาน — chat-center-api/src/modules/chat/chat.service.ts (branch: prod):96-99

  1. สร้าง SQL โดย interpolate ค่าจาก JWT และ query string:
typescript
let queryString = `SELECT ... FROM chatRoom where sourceDB = '${user.profile.sourceDB}' `;
if (query.fullName) queryString += ` and fullName like '%${query.fullName}%'  `;
if (query.diviCode) queryString += ` and diviCode = '${query.diviCode}' `;
if (query.status) queryString += ` and status = '${query.status}'  `;
if (query.tagsId) queryString += ` and tagsId like '%"${query.tagsId}"%'   `;

หลักฐาน — chat-center-api/src/modules/chat/chat.service.ts (branch: prod):103-140

ค่า fullName, diviCode, status, tagsId มาจาก FilterRoomDTO (HTTP query) โดยตรง — เปิดช่อง SQL injection (SEC-CHAT-01)

  1. Staff ที่ไม่ใช่ super admin (level !== 1) ถูกจำกัดเพิ่มด้วย tagsId like '%"{tagsId}"%' จากทีมของตัวเอง หรือ noteTag like '%"{empCode}"%' (chat.service.ts:142-155)

  2. หลังได้รายการห้อง วน loop:

    • parse tagsId JSON → TagsEntity.find({ id: In(tags) })
    • ถ้าไม่ใช่ level 1 และหาไม่เจอ TeamsTagsEntity ที่ isShow: truerooms.splice(index, 1) ลบออกจากผลลัพธ์
    • ดึง ChatRoomStatusHistoryEntity ล่าสุดเป็น statusLasted
    • ดึง Conversation ล่าสุดจาก Mongo เป็น lastmsg

หลักฐาน — chat-center-api/src/modules/chat/chat.service.ts (branch: prod):159-200

สถานะห้อง (status) ตาม enum RoomStatus ใน DTO: w (waiting), p (processing), s (success/closed) (chat-center-api/src/dto/chat.dto.ts (branch: prod):34-38)


Pipeline B — Socket.IO event chat

WebSocket ไม่ผ่าน JwtAuthGuard ของ HTTP — global guard ผูกกับ HTTP adapter เท่านั้น (main.ts:34)

text
Client emit 'chat' { fromline?, lineToken, message, base64?, fileName?, empCode? }

ChatGateway.handleMessage(message)
   │  chat.gateway.ts:49-230
   ├─ ถ้า message.fromline === true
   │     └─ server.emit(replyToken, { data, type:'text', sendBy:'user' })
   │        return  (ไม่ push LINE, ไม่เขียน Mongo ใน branch นี้)

   └─ else (ถือว่าเป็นแอดมินตอบ)
         ├─ SELECT userLine WHERE lineID = @0          (main)
         ├─ SELECT temp_pee_na_table WHERE EmpCode=@0  (center)
         ├─ ถ้ามี base64 → เขียนไฟล์ ./uploads/{YYYY-MM-DD}/ + push text+URL ไป LINE
         └─ else → push text ไป LINE + save Conversation (adminAction*)

Branch fromline === true

typescript
if (message?.fromline) {
  this.server.emit(message.replyToken, {
    data: message.message,
    type: 'text',
    sendBy: 'user',
  });
  return true;
}

หลักฐาน — chat-center-api/src/modules/chat/chat.gateway.ts (branch: prod):58-66

Flag fromline มาจาก client payload โดยตรง — ไม่มี signature / JWT ตรวจแหล่งที่มา (SEC-CHAT-02) ในทางปฏิบัติ webhook ของ LINE จะเรียก handleMessage ด้วย fromline: true จากฝั่ง server (app.service.ts:91-95) แต่ client อื่นก็ emit ค่าเดียวกันได้

Branch แอดมินตอบ (ไม่มี base64)

  1. Lookup userLine + HRMI profile (parameterized @0 — จุดนี้ปลอดภัยกว่า rooms())
  2. server.emit(lineToken, { data, type:'text', sendBy:'admin' })
  3. POST https://api.line.me/v2/bot/message/push ด้วย Authorization: Bearer ${lineAuth} (chat.gateway.ts:52-56,193-194)
  4. บันทึก Conversation ที่ adminActionMessage / adminActionTime / adminActionEmpCode

หลักฐาน — chat-center-api/src/modules/chat/chat.gateway.ts (branch: prod):177-223

Branch มีไฟล์แนบ (base64)

  1. mime.lookup(fileName) → extension → ชื่อไฟล์สุ่มจาก timestamp+random
  2. เขียน fs.writeFileSync ที่ ./uploads/{YYYY-MM-DD}/{rename} เป็น base64
  3. ถ้ามี message.message ด้วย — push text ไป LINE + save Conversation type text
  4. Push URL ไฟล์เป็นข้อความ text ไป LINE (ไม่ใช่ LINE image/file message type) + save Conversation type file

URL ที่ส่ง: `${hostServer}${filePath}${rename}` โดย hostServer = SERVER_URL/BASE_PATH (configuration.ts:6)

หลักฐาน — chat-center-api/src/modules/chat/chat.gateway.ts (branch: prod):77-175

Dead code ที่ถูก comment: พยายามยิงผ่าน gateway http://20.212.116.206:8200/api/v1/line เพราะ "ติดปัญหา firewall, network" (chat.gateway.ts:109-117,150-159,196-208) — ปัจจุบันยิง LINE โดยตรง


Pipeline C — LINE webhook จริง (POST /webhook)

text
LINE Platform
   │  POST /chat-center-api/api/v1/webhook
   │  (ไม่พบการ verify X-Line-Signature ในโค้ด)

AppController.webhook(@Public())     app.controller.ts:18-22
   │  ไม่ await — เรียก this.service.actions(body) fire-and-forget

AppService.actions(body: LineBody)   app.service.ts:29-186

   ├─ ถ้า events[0].type !== 'message' → ไม่ทำอะไร
   ├─ SELECT userLine WHERE lineID = @0
   ├─ SELECT temp_pee_na_table WHERE EmpCode = userID
   ├─ หา/สร้าง ChatRoomEntity (unique empCode+sourceDB)
   │     + ChatRoomStatusHistoryEntity status='w'
   └─ switch (message.text):
         ├─ 'ติดต่อแอดมิน'
         │     emit fromline + bot ตอบรายการ tag + save Conversation + isChat=true
         ├─ text ตรงกับ tagName
         │     เพิ่ม tagsId ในห้อง + bot ตอบรับเรื่อง + isChat=true
         └─ default
               ถ้า room.isChat == true → emit fromline + save Conversation

สร้างห้องอัตโนมัติ

เมื่อพนักงานที่มี userLine mapping ส่งข้อความครั้งแรกและยังไม่มีห้อง:

typescript
const dataCreateRoom = await this.entityManager.save(ChatRoomEntity, {
  empCode: profile[0].EmpCode,
  identityCard: profile[0].IdentityCard,
  fullName: `${profile[0].Title}${profile[0].FirstName} ${profile[0].LastName}`,
  // ... org/position/divi/dept จาก HRMI
  replyToken: body.events[0].source.userId,
  tagsId: JSON.stringify([]),
  noteTag: JSON.stringify([]),
  // ...
});
await this.entityManager.save(ChatRoomStatusHistoryEntity, {
  chatRoomId: dataCreateRoom.id,
  status: 'w',
  createdBy: 'SYSTEM ADMIN',
  // ...
});

หลักฐาน — chat-center-api/src/app.service.ts (branch: prod):46-76

Keyword ติดต่อแอดมิน

  1. handleMessage({ fromline: true, replyToken, message }) — broadcast ไปหน้าเว็บ
  2. handleMessage({ empCode: 'bot', lineToken, message: รายการ tag }) — push รายการหัวข้อจาก TagsEntity ของ sourceDB นั้นกลับไป LINE
  3. บันทึก Conversation ฝั่ง user + update ChatRoom isRead=false, isChat=true

หลักฐาน — chat-center-api/src/app.service.ts (branch: prod):86-118

เลือกหัวข้อ (tag name ตรงข้อความ)

อัปเดต tagsId ของห้องโดย push id ของ tag ที่ match → bot ตอบ "ระบบได้รับเรื่องแล้ว..." → save Conversation → isChat=true

หลักฐาน — app.service.ts:120-153

แชทเปิดอยู่ (isChat == true)

ข้อความทั่วไปจะถูก broadcast + บันทึก Mongo โดยไม่ต้อง match keyword (app.service.ts:155-176)

ช่องโหว่ webhook

  • Endpoint เป็น @Public() และ ไม่พบ การตรวจ X-Line-Signature / HMAC ด้วย LINE_CHANNEL_SECRET ใน AppController หรือ AppService (SEC-CHAT-03)
  • webhook() ไม่ await และไม่ return response body ชัดเจน — LINE อาจได้ 201/200 ว่างแม้ logic ภายใน throw ทีหลัง

Pipeline D — เปลี่ยนสถานะห้อง + ส่งแบบสำรวจ

POST /createChatHistoryAppService.createChatHistory (app.service.ts:188-261)

text
Admin เปลี่ยนสถานะ
   │  body: { chatRoomId, status: 'w'|'p'|'s' }

transaction:
   ├─ save ChatRoomStatusHistoryEntity
   ├─ save ChatRoomEntity.status
   ├─ ถ้ามี care taker คนอื่น → NotificationsEntity "เปลี่ยนสถานะแชท"
   ├─ ถ้า status === 's' (ปิดงาน)
   │     ├─ save SatisfactionSurveyEntity (ได้ key จาก NEWID())
   │     └─ push LINE ลิงก์ Feedback/{key} ไป UAT URL แบบ hardcode
   └─ ถ้า status === 'p'
         └─ push LINE "ผู้ดูแลระบบได้รับเรื่องแล้ว..."

URL แบบสำรวจที่ hardcode:

text
https://uat-vtrc.redcross.or.th/2way-backoffice/2wayfrontend/2waybackoffice/Feedback/{key}

หลักฐาน — chat-center-api/src/app.service.ts (branch: prod):223-248

แม้ .env จะชี้ SERVER_URL เป็น production (vtrcapi.redcross.or.th) แต่ลิงก์ feedback ในข้อความ LINE ยังชี้ UAT front — ความเสี่ยงส่ง user ไปผิด environment (CORR-CHAT-03)


Pipeline E — นัดหมาย + in-memory reminder

text
POST /appointment

AppointmentService.createAppointment
   ├─ save AppointmentEntity (MSSQL main)
   └─ new CronJob(timeJob, callback).start()
         │  timeJob = appointmentDate - timeDiff/timeUnit
         └─ เมื่อถึงเวลา: save NotificationsEntity ให้ care taker หรือ superAdmin

หลักฐาน — chat-center-api/src/modules/appointment/appointment.service.ts (branch: prod):24-68

timeDiff / timeUnit มาจาก body และต้องตรงกับตัวเลือกใน appointmentDD config (configuration.ts:45-66) — dropdown สาธารณะที่ GET /appointment/dropDown (@Public())

ข้อจำกัดCronJob จาก package cron อยู่แค่ใน memory ของ process; ถ้า deploy/crash ก่อนถึงเวลา แจ้งเตือนจะหายโดยไม่ re-register ตอน boot (CORR-CHAT-01) และ updateAppointment ไม่ สร้าง job ใหม่ (appointment.service.ts:74-86)


Pipeline F — Cron รายวัน

clearTransaction (01:00)

typescript
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 90);
await this.conversationModel.deleteMany({ createdAt: { $lt: cutoffDate } });
await this.chatNoteModel.deleteMany({ createdAt: { $lt: cutoffDate } });

หลักฐาน — chat-center-api/src/modules/cronJobs/cronjobs.service.ts (branch: prod):26-41

ลบเฉพาะ Mongo — ไม่ ลบ chatRoom / history ใน MSSQL

checkStatusAnd24H (midnight)

  1. Raw SQL หาแถว chatRoomStatusHistory ที่ createdAt เป็นค่าล่าสุดต่อ chatRoomId
  2. ถ้า moment().diff(createdAt, 'hours') >= 24:
    • มี careTakerEmpCode → notification ข้อความ `คุณมีการนัดหมายกับ ${fullName}` (ข้อความไม่ตรงบริบท "รอตอบกลับ" — copy ผิดจาก appointment?)
    • ไม่มี care taker → superAdmin: true ข้อความ `${fullName} รอตอบกลับ`

หลักฐาน — cronjobs.service.ts:47-88


Pipeline G — WebSocket chatNote

Internal note ของ admin (ไม่ส่ง LINE):

  1. server.emit(roomEmpCode, { data: {...} })
  2. บันทึก ChatNote document
  3. ถ้ามี care taker คนอื่น → notification "แสดงความคิดเห็นในโน้ต"
  4. ถ้ามี tagEmpCode → notification "แท็กคุณในโน้ต"

หลักฐาน — chat-center-api/src/modules/chat/chat.gateway.ts (branch: prod):232-294

เช่นเดียวกับ event chat — ไม่มี JWT บน handshake


สรุปความต่าง auth ระหว่าง pipeline

PipelineAuthหลักฐาน
REST ส่วนใหญ่Bearer JWT (global guard)main.ts:34
REST @Public()ไม่มีwebhook, test, appointment/dropDown, survey public/checkKey
Socket.IO chat / chatNoteไม่มีchat.gateway.ts:19-26
Nest cronไม่มี (process ภายใน)cronjobs.controller.ts
In-memory appointment CronJobไม่มีappointment.service.ts:37