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:
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 / MongooseCORS เปิด 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:
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
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 ConversationStep ละเอียด
- หาสมาชิกทีมของ empCode จาก JWT:
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
- สร้าง SQL โดย interpolate ค่าจาก JWT และ query string:
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)
Staff ที่ไม่ใช่ super admin (
level !== 1) ถูกจำกัดเพิ่มด้วยtagsId like '%"{tagsId}"%'จากทีมของตัวเอง หรือnoteTag like '%"{empCode}"%'(chat.service.ts:142-155)หลังได้รายการห้อง วน loop:
- parse
tagsIdJSON →TagsEntity.find({ id: In(tags) }) - ถ้าไม่ใช่ level 1 และหาไม่เจอ
TeamsTagsEntityที่isShow: true→rooms.splice(index, 1)ลบออกจากผลลัพธ์ - ดึง
ChatRoomStatusHistoryEntityล่าสุดเป็นstatusLasted - ดึง
Conversationล่าสุดจาก Mongo เป็นlastmsg
- parse
หลักฐาน — 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)
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
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)
- Lookup
userLine+ HRMI profile (parameterized@0— จุดนี้ปลอดภัยกว่าrooms()) server.emit(lineToken, { data, type:'text', sendBy:'admin' })POST https://api.line.me/v2/bot/message/pushด้วยAuthorization: Bearer ${lineAuth}(chat.gateway.ts:52-56,193-194)- บันทึก
Conversationที่adminActionMessage/adminActionTime/adminActionEmpCode
หลักฐาน — chat-center-api/src/modules/chat/chat.gateway.ts (branch: prod):177-223
Branch มีไฟล์แนบ (base64)
mime.lookup(fileName)→ extension → ชื่อไฟล์สุ่มจาก timestamp+random- เขียน
fs.writeFileSyncที่./uploads/{YYYY-MM-DD}/{rename}เป็น base64 - ถ้ามี
message.messageด้วย — push text ไป LINE + save Conversation typetext - 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)
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 ส่งข้อความครั้งแรกและยังไม่มีห้อง:
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 ติดต่อแอดมิน
handleMessage({ fromline: true, replyToken, message })— broadcast ไปหน้าเว็บhandleMessage({ empCode: 'bot', lineToken, message: รายการ tag })— push รายการหัวข้อจากTagsEntityของsourceDBนั้นกลับไป LINE- บันทึก 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 /createChatHistory → AppService.createChatHistory (app.service.ts:188-261)
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:
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
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)
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)
- Raw SQL หาแถว
chatRoomStatusHistoryที่createdAtเป็นค่าล่าสุดต่อchatRoomId - ถ้า
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):
server.emit(roomEmpCode, { data: {...} })- บันทึก
ChatNotedocument - ถ้ามี care taker คนอื่น → notification "แสดงความคิดเห็นในโน้ต"
- ถ้ามี
tagEmpCode→ notification "แท็กคุณในโน้ต"
หลักฐาน — chat-center-api/src/modules/chat/chat.gateway.ts (branch: prod):232-294
เช่นเดียวกับ event chat — ไม่มี JWT บน handshake
สรุปความต่าง auth ระหว่าง pipeline
| Pipeline | Auth | หลักฐาน |
|---|---|---|
| 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 |