ส่งคำตอบฟอร์ม (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
- เรียก
getByHashเพื่อโหลดฟอร์ม (ตอบ 400 ถ้าไม่มีหรือไม่ active) - verify
x-liff-tokenเพื่อให้ได้lineUserIdโดยการ verify ที่ล้มเหลวหมายถึงไม่มีผู้ใช้ ไม่ใช่ error - หากฟอร์มตั้งค่า
requireLineLoginแต่ไม่มี user จะตอบ 401Authentication required for this form - หากมี user และมีแถวเดิมอยู่แล้ว จะคืนแถวเดิมโดยไม่สร้างซ้ำ
- หากยังไม่มี จะ insert แถว placeholder ที่
is_submitted = falseแล้วคืน entity ด้วย status 201
POST /api/form-submission/:hash
endpoint นี้รับได้ทั้ง multipart/form-data, urlencoded และ JSON โดยลำดับการตรวจมีความสำคัญมาก เพราะแต่ละขั้นเป็นด่านของขั้นถัดไป
- โหลดฟอร์ม แล้ว verify token แล้วตรวจ
requireLineLoginเช่นเดียวกับ endpoint ด้านบน - ลบ key
lineUserIdออกจาก body เพื่อใช้ค่าจาก token เท่านั้น และดึงotpRefออกไปเก็บไว้ต่างหากเพราะไม่ใช่คำตอบของคำถาม - แปลง body ให้อยู่ในรูป
{questionId: {questionId, questionType, value}}โดยเทียบquestionTypeจากนิยามฟอร์ม - ตรวจ question id ที่ไม่รู้จัก — key ใดที่ไม่ตรงกับคำถามใดในฟอร์มจะทำให้ตอบ 400 พร้อมข้อความ
Invalid question IDs found: ... These IDs do not exist in the form questions. - validate คำตอบ ด้วย regex และการตรวจความยาว หากล้มเหลวจะคืน 400 พร้อม body แบบ object ดิบ คือ
{message:"Form validation failed", errors:[...], details:"..."}ซึ่งไม่ใช่ envelope ปกติ รายละเอียดดูที่ ตรวจความถูกต้องของคำตอบฟอร์ม - profile mapping — จับคู่คำตอบชนิด
db_validationกับฐานข้อมูลลูกค้า ซึ่งอาจตอบ 400 พร้อม{code:"PROFILE_NOT_FOUND"}หรือ{code:"RECORD_ALREADY_CLAIMED"}รายละเอียดดูที่ จับคู่ผู้กรอกกับฐานข้อมูลลูกค้า - ด่าน OTP — เฉพาะฟอร์มที่เปิดใช้ OTP โดยต้องมี session ที่ verified แล้ว และ
matchedRowIdของ session ต้องเท่ากับแถวที่เพิ่ง match ใหม่ฝั่ง server มิฉะนั้นจะตอบ 400 พร้อม{code:"OTP_REQUIRED"}ทั้งนี้ธง verified ที่ส่งมาจากฝั่ง client ไม่ถูกเชื่อเลย - ย้ายไฟล์แนบ — คำตอบชนิด
file_uploadที่เป็น URL ในtemp/จะถูก copy ไปยังform-builder/{formId}/{filename}โดยตรวจชื่อไฟล์ด้วย^[a-zA-Z0-9._-]+$ตัด public endpoint ออกก่อน แล้วตัดทุกอย่างที่อยู่ก่อน/temp/ออก ส่วน path ที่ไม่ได้ขึ้นต้นด้วยtemp/จะถูกข้ามไป - บันทึกข้อมูล — คอลัมน์
metadataเก็บ{profileMapping:{matchedRowId, databaseId}}เมื่อ match ได้ โดยdatabaseIdเก็บค่าดิบตาม config ไม่ coerce ใหม่ จากนั้นทำ upsert ตามกติกา- หากมี draft ที่
is_submitted = falseอยู่ จะ update แถวนั้น - หากไม่มี จะนับจำนวนแถวที่ submitted แล้ว ถ้าฟอร์มตั้ง
oneTimeSubmissionและนับได้มากกว่า 0 จะตอบ 400You have reached the maximum number of submissions for this form.มิฉะนั้นจึง insert แถวใหม่ - หมายเหตุเรื่อง parity: คอลัมน์
responses_countไม่เคยถูก increment
- หากมี draft ที่
- Side effect หลังบันทึก
- เมื่อ
convert_to_memberเปิดอยู่ จะ publishsetRichMenuMemberByLineUserIdโดย error ที่เกิดตอน publish จะ propagate ทำให้ request ล้มเหลวตาม source เดิม พร้อมกับสั่งUPDATE line_user SET user_type='member'แบบ best-effort ซึ่งแก้ defect ที่ payloaduserTypeไม่มี worker ตัวใดอ่าน - เมื่อ
thankYou.action.rich_menuถูกตั้งไว้ จะ publishsetRichMenuByTriggerRuleแบบ 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 ลง jsonbcustom_attributeสำหรับ key ที่ขึ้นต้นด้วยcustom.โดย coerce ค่าตามdataType(ค่าชนิดnumberที่เป็น NaN จะถูกข้าม ส่วนbooleanเทียบกับ"true"หรือtrue) และ error ถูกกลืนเช่นกัน- เมื่อ match สำเร็จจะเรียก
ApplyVerifiedAttributeเพื่อ merge ค่า verified attribute ลงในcustom_attribute
- เมื่อ
- คืน
{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} ตามปกติ
ไฟล์และฟังก์ชันหลัก
| Route | Handler |
|---|---|
POST /api/form-submission/:hash/form-submit-incompleted | internal/formsubmission/handler.go → (*Handler).FormSubmitIncompleted |
POST /api/form-submission/:hash | (*Handler).Submit |
internal/formsubmission/register.go→Register(r, deps)ประกอบ formbuilder service, validation, profile mapping, liff adapter, storage adapter, AMQP publisher และ OTP serviceinternal/formsubmission/service.go→Submit,FormSubmitIncompleted,extractLineUserID,updateUserProfileIfNeeded,applyFieldAttributeMappings,buildQuestionTypeMap,buildValidQuestionIDs,coerceNumberinternal/formsubmission/handler.go→parseSubmitBody,formValue,decodeJSONValue,abortinternal/formsubmission/repository.go→FindOneExisting,SaveIncomplete,FindNotSubmitted,CountSubmitted,Save,UpdateByID,FindByIDinternal/formsubmission/errors.go→objectError,newValidationError,newCodeErrorinternal/formsubmission/entity.go→FormSubmission,SaveInputและค่าคงที่ชนิดคำถามอย่างQEmail,QFileUpload,QDbValidation
จุดเชื่อมต่อกับ Service อื่น
- ตาราง
form_submission,form_builder,line_user,rich_menu,customer_databaseและcustomer_database_row - RabbitMQ queue
line_change_richmenuโดยชื่อมาจากdeps.Config.RabbitMQ.QueueLineChangeRichmenu - Object storage ผ่าน
CopyObjectสำหรับย้ายไฟล์แนบ โดยใช้deps.Config.Storage.PublicHostในการตัด prefix - feature ที่ประกอบร่วมกัน ได้แก่ โหลดโครงฟอร์ม, ตรวจความถูกต้องของคำตอบฟอร์ม, จับคู่ผู้กรอกกับฐานข้อมูลลูกค้า, ยืนยันตัวตนด้วย OTP, หน้า Thank You และ Rich Menu, อัปโหลดไฟล์ชั่วคราว และ การตรวจสิทธิ์ LIFF Token
- ฝั่ง client-web ที่เกี่ยวข้องคือ feature
form-fillและform-thank-you