Form Answer Submission
Overview
The longest pipeline in this service. It takes answers from the form page, validates them through several layers, writes them into form_submission, and then fires a series of side effects: copying attachments, updating the LINE user's profile, writing custom attributes, switching rich menus, and converting guests into members.
There are two endpoints: a draft save (form-submit-incompleted, used when a user opens the form so a placeholder exists for funnel measurement) and the real submission at POST /form-submission/:hash.
Business Flow
POST /api/form-submission/:hash/form-submit-incompleted
getByHashloads the form (400 if it is missing or inactive).- Verify
x-liff-tokento obtainlineUserId; a failed verification simply means there is no user, not an error. - If the form sets
requireLineLoginbut there is no user, return 401Authentication required for this form. - If a user is present and a row already exists, return the existing row rather than creating a duplicate.
- Otherwise insert a placeholder row with
is_submitted = falseand return the entity with status 201.
POST /api/form-submission/:hash
This endpoint accepts multipart/form-data, urlencoded, and JSON. The order of checks matters a great deal, because each step gates the next.
- Load the form, verify the token, and check
requireLineLogin, exactly as above. - Remove the
lineUserIdkey from the body so only the token's value is used, and extractotpRefseparately since it is not an answer to any question. - Normalize the body into
{questionId: {questionId, questionType, value}}, resolvingquestionTypefrom the form definition. - Reject unknown question IDs — any key that matches no question in the form returns 400 with
Invalid question IDs found: ... These IDs do not exist in the form questions. - Validate the answers with regex and length checks. A failure returns 400 with a raw object body:
{message:"Form validation failed", errors:[...], details:"..."}— not the usual envelope. See Form Answer Validation. - Profile mapping matches
db_validationanswers against the customer database, and may return 400 with{code:"PROFILE_NOT_FOUND"}or{code:"RECORD_ALREADY_CLAIMED"}. See Matching Respondents to the Customer Database. - The OTP gate applies only to OTP-enabled forms. A verified session must exist and the session's
matchedRowIdmust equal the row the server just matched; otherwise the response is 400 with{code:"OTP_REQUIRED"}. A client-supplied "verified" flag is never trusted. - Move attachments —
file_uploadanswers whose value is a URL undertemp/are copied toform-builder/{formId}/{filename}. The filename is validated against^[a-zA-Z0-9._-]+$, the public endpoint is stripped first, and everything before/temp/is removed. Paths that do not start withtemp/are skipped. - Save —
metadatastores{profileMapping:{matchedRowId, databaseId}}when a match was found, withdatabaseIdkept as the raw configured value rather than re-coerced. Then the upsert runs:- If a draft with
is_submitted = falseexists, update that row. - Otherwise count the already-submitted rows; if the form sets
oneTimeSubmissionand the count is greater than 0, return 400You have reached the maximum number of submissions for this form.Otherwise insert a new row. - Parity note:
responses_countis never incremented.
- If a draft with
- Post-save side effects
- When
convert_to_memberis on, publishsetRichMenuMemberByLineUserId. A publish error propagates and fails the request, matching the source, andUPDATE line_user SET user_type='member'runs best-effort — a fix for the defect where theuserTypepayload was read by no worker at all. - When
thankYou.action.rich_menuis set, publishsetRichMenuByTriggerRule, entirely best-effort. updateUserProfileIfNeededwritesfirst_name,last_name,email, andphoneanswers back to the correspondingline_usercolumns.citizen_idanddate_of_birthare intentionally discarded because those columns do not exist (the original TypeORM ignored them too), and all errors are swallowed.applyFieldAttributeMappingswrites mapped values into fixed columns from an allowlist —display_name,firstname,lastname,email,mobile_no,language— and/or merges them into the jsonbcustom_attributefor keys prefixed withcustom.. Values are coerced bydataType(anumberthat comes out NaN is skipped; abooleanis compared against"true"ortrue). Errors are swallowed here as well.- On a successful match,
ApplyVerifiedAttributemerges the verified attribute intocustom_attribute.
- When
- Return
{body, formBuilderInfo, message:"Form submitted successfully"}with status 201.
Two error shapes
handler.abort checks whether the error is an *objectError. If so, it sends that object as the raw body — the behavior of the NestJS default filter when an HttpException is constructed from an object. Otherwise it goes through the usual {statusCode,message,error} envelope.
Key Files & Functions
| 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)wires the formbuilder service, validation, profile mapping, the liff adapter, the storage adapter, the AMQP publisher, and the 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, and the question-type constants such asQEmail,QFileUpload, andQDbValidation
Connections to Other Services
- The
form_submission,form_builder,line_user,rich_menu,customer_database, andcustomer_database_rowtables. - The RabbitMQ queue
line_change_richmenu, named fromdeps.Config.RabbitMQ.QueueLineChangeRichmenu. - Object storage via
CopyObjectfor moving attachments, withdeps.Config.Storage.PublicHostused to strip the prefix. - Collaborating features: Loading a Form's Structure, Form Answer Validation, Matching Respondents to the Customer Database, OTP Verification, Thank You Page & Rich Menu, Temporary File Upload, and LIFF Token Verification.
- The related client-web features are
form-fillandform-thank-you.