Skip to main content

ส่งคำตอบฟอร์ม (Form Submission)

ภาพรวม

pipeline ที่ยาวที่สุดใน service นี้ ทำหน้าที่รับคำตอบจากหน้ากรอกฟอร์ม ตรวจสอบหลายชั้น บันทึกลงตาราง form_submission แล้วทำ side effect ต่อเนื่องอีกหลายอย่าง ได้แก่ การคัดลอกไฟล์แนบ การอัปเดตโปรไฟล์ LINE user การเขียน custom attribute การสลับ rich menu และการแปลง guest ให้เป็น member

feature นี้มี 2 endpoint คือการบันทึกร่าง (form-submit-incompleted ซึ่งใช้ตอนผู้ใช้เปิดฟอร์มเพื่อสร้าง placeholder สำหรับวัด funnel) และการส่งจริงที่ POST /form-submission/:hash

Business Flow

POST /api/form-submission/:hash/form-submit-incompleted

  1. เรียก getByHash เพื่อโหลดฟอร์ม (ตอบ 400 ถ้าไม่มีหรือไม่ active)
  2. verify x-liff-token เพื่อให้ได้ lineUserId โดยการ verify ที่ล้มเหลวหมายถึงไม่มีผู้ใช้ ไม่ใช่ error
  3. หากฟอร์มตั้งค่า requireLineLogin แต่ไม่มี user จะตอบ 401 Authentication required for this form
  4. หากมี user และมีแถวเดิมอยู่แล้ว จะคืนแถวเดิมโดยไม่สร้างซ้ำ
  5. หากยังไม่มี จะ insert แถว placeholder ที่ is_submitted = false แล้วคืน entity ด้วย status 201

POST /api/form-submission/:hash

endpoint นี้รับได้ทั้ง multipart/form-data, urlencoded และ JSON โดยลำดับการตรวจมีความสำคัญมาก เพราะแต่ละขั้นเป็นด่านของขั้นถัดไป

  1. โหลดฟอร์ม แล้ว verify token แล้วตรวจ requireLineLogin เช่นเดียวกับ endpoint ด้านบน
  2. ลบ key lineUserId ออกจาก body เพื่อใช้ค่าจาก token เท่านั้น และดึง otpRef ออกไปเก็บไว้ต่างหากเพราะไม่ใช่คำตอบของคำถาม
  3. แปลง body ให้อยู่ในรูป {questionId: {questionId, questionType, value}} โดยเทียบ questionType จากนิยามฟอร์ม
  4. ตรวจ question id ที่ไม่รู้จัก — key ใดที่ไม่ตรงกับคำถามใดในฟอร์มจะทำให้ตอบ 400 พร้อมข้อความ Invalid question IDs found: ... These IDs do not exist in the form questions.
  5. validate คำตอบ ด้วย regex และการตรวจความยาว หากล้มเหลวจะคืน 400 พร้อม body แบบ object ดิบ คือ {message:"Form validation failed", errors:[...], details:"..."} ซึ่งไม่ใช่ envelope ปกติ รายละเอียดดูที่ ตรวจความถูกต้องของคำตอบฟอร์ม
  6. profile mapping — จับคู่คำตอบชนิด db_validation กับฐานข้อมูลลูกค้า ซึ่งอาจตอบ 400 พร้อม {code:"PROFILE_NOT_FOUND"} หรือ {code:"RECORD_ALREADY_CLAIMED"} รายละเอียดดูที่ จับคู่ผู้กรอกกับฐานข้อมูลลูกค้า
  7. ด่าน OTP — เฉพาะฟอร์มที่เปิดใช้ OTP โดยต้องมี session ที่ verified แล้ว และ matchedRowId ของ session ต้องเท่ากับแถวที่เพิ่ง match ใหม่ฝั่ง server มิฉะนั้นจะตอบ 400 พร้อม {code:"OTP_REQUIRED"} ทั้งนี้ธง verified ที่ส่งมาจากฝั่ง client ไม่ถูกเชื่อเลย
  8. ย้ายไฟล์แนบ — คำตอบชนิด file_upload ที่เป็น URL ใน temp/ จะถูก copy ไปยัง form-builder/{formId}/{filename} โดยตรวจชื่อไฟล์ด้วย ^[a-zA-Z0-9._-]+$ ตัด public endpoint ออกก่อน แล้วตัดทุกอย่างที่อยู่ก่อน /temp/ ออก ส่วน path ที่ไม่ได้ขึ้นต้นด้วย temp/ จะถูกข้ามไป
  9. บันทึกข้อมูล — คอลัมน์ metadata เก็บ {profileMapping:{matchedRowId, databaseId}} เมื่อ match ได้ โดย databaseId เก็บค่าดิบตาม config ไม่ coerce ใหม่ จากนั้นทำ upsert ตามกติกา
    • หากมี draft ที่ is_submitted = false อยู่ จะ update แถวนั้น
    • หากไม่มี จะนับจำนวนแถวที่ submitted แล้ว ถ้าฟอร์มตั้ง oneTimeSubmission และนับได้มากกว่า 0 จะตอบ 400 You have reached the maximum number of submissions for this form. มิฉะนั้นจึง insert แถวใหม่
    • หมายเหตุเรื่อง parity: คอลัมน์ responses_count ไม่เคยถูก increment
  10. Side effect หลังบันทึก
    • เมื่อ convert_to_member เปิดอยู่ จะ publish setRichMenuMemberByLineUserId โดย error ที่เกิดตอน publish จะ propagate ทำให้ request ล้มเหลวตาม source เดิม พร้อมกับสั่ง UPDATE line_user SET user_type='member' แบบ best-effort ซึ่งแก้ defect ที่ payload userType ไม่มี worker ตัวใดอ่าน
    • เมื่อ thankYou.action.rich_menu ถูกตั้งไว้ จะ publish setRichMenuByTriggerRule แบบ best-effort ล้วน
    • updateUserProfileIfNeeded เขียนคำตอบชนิด first_name, last_name, email และ phone กลับไปยังคอลัมน์ของ line_user ส่วน citizen_id และ date_of_birth ถูกทิ้งโดยเจตนาเพราะคอลัมน์ไม่มีอยู่จริง (TypeORM เดิมก็ ignore เช่นกัน) และ error ทั้งหมดถูกกลืน
    • applyFieldAttributeMappings เขียนค่าตาม mapping ลงคอลัมน์คงที่ตาม allowlist ได้แก่ display_name, firstname, lastname, email, mobile_no, language และ/หรือ merge ลง jsonb custom_attribute สำหรับ key ที่ขึ้นต้นด้วย custom. โดย coerce ค่าตาม dataType (ค่าชนิด number ที่เป็น NaN จะถูกข้าม ส่วน boolean เทียบกับ "true" หรือ true) และ error ถูกกลืนเช่นกัน
    • เมื่อ match สำเร็จจะเรียก ApplyVerifiedAttribute เพื่อ merge ค่า verified attribute ลงใน custom_attribute
  11. คืน {body, formBuilderInfo, message:"Form submitted successfully"} ด้วย status 201

การแปลง error 2 แบบ

handler.abort ตรวจว่า error เป็น *objectError หรือไม่ หากใช่จะส่ง body object นั้นออกไปดิบ ๆ ซึ่งเป็นพฤติกรรมของ NestJS default filter เมื่อ HttpException ถูกสร้างด้วย object แต่ถ้าไม่ใช่ก็จะส่งผ่าน envelope {statusCode,message,error} ตามปกติ

ไฟล์และฟังก์ชันหลัก

RouteHandler
POST /api/form-submission/:hash/form-submit-incompletedinternal/formsubmission/handler.go(*Handler).FormSubmitIncompleted
POST /api/form-submission/:hash(*Handler).Submit
  • internal/formsubmission/register.goRegister(r, deps) ประกอบ formbuilder service, validation, profile mapping, liff adapter, storage adapter, AMQP publisher และ OTP service
  • internal/formsubmission/service.goSubmit, FormSubmitIncompleted, extractLineUserID, updateUserProfileIfNeeded, applyFieldAttributeMappings, buildQuestionTypeMap, buildValidQuestionIDs, coerceNumber
  • internal/formsubmission/handler.goparseSubmitBody, formValue, decodeJSONValue, abort
  • internal/formsubmission/repository.goFindOneExisting, SaveIncomplete, FindNotSubmitted, CountSubmitted, Save, UpdateByID, FindByID
  • internal/formsubmission/errors.goobjectError, newValidationError, newCodeError
  • internal/formsubmission/entity.goFormSubmission, SaveInput และค่าคงที่ชนิดคำถามอย่าง QEmail, QFileUpload, QDbValidation

จุดเชื่อมต่อกับ Service อื่น