Skip to main content

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

  1. getByHash loads the form (400 if it is missing or inactive).
  2. Verify x-liff-token to obtain lineUserId; a failed verification simply means there is no user, not an error.
  3. If the form sets requireLineLogin but there is no user, return 401 Authentication required for this form.
  4. If a user is present and a row already exists, return the existing row rather than creating a duplicate.
  5. Otherwise insert a placeholder row with is_submitted = false and 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.

  1. Load the form, verify the token, and check requireLineLogin, exactly as above.
  2. Remove the lineUserId key from the body so only the token's value is used, and extract otpRef separately since it is not an answer to any question.
  3. Normalize the body into {questionId: {questionId, questionType, value}}, resolving questionType from the form definition.
  4. 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.
  5. 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.
  6. Profile mapping matches db_validation answers 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.
  7. The OTP gate applies only to OTP-enabled forms. A verified session must exist and the session's matchedRowId must equal the row the server just matched; otherwise the response is 400 with {code:"OTP_REQUIRED"}. A client-supplied "verified" flag is never trusted.
  8. Move attachmentsfile_upload answers whose value is a URL under temp/ are copied to form-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 with temp/ are skipped.
  9. Savemetadata stores {profileMapping:{matchedRowId, databaseId}} when a match was found, with databaseId kept as the raw configured value rather than re-coerced. Then the upsert runs:
    • If a draft with is_submitted = false exists, update that row.
    • Otherwise count the already-submitted rows; if the form sets oneTimeSubmission and the count is greater than 0, return 400 You have reached the maximum number of submissions for this form. Otherwise insert a new row.
    • Parity note: responses_count is never incremented.
  10. Post-save side effects
    • When convert_to_member is on, publish setRichMenuMemberByLineUserId. A publish error propagates and fails the request, matching the source, and UPDATE line_user SET user_type='member' runs best-effort — a fix for the defect where the userType payload was read by no worker at all.
    • When thankYou.action.rich_menu is set, publish setRichMenuByTriggerRule, entirely best-effort.
    • updateUserProfileIfNeeded writes first_name, last_name, email, and phone answers back to the corresponding line_user columns. citizen_id and date_of_birth are intentionally discarded because those columns do not exist (the original TypeORM ignored them too), and all errors are swallowed.
    • applyFieldAttributeMappings writes mapped values into fixed columns from an allowlist — display_name, firstname, lastname, email, mobile_no, language — and/or merges them into the jsonb custom_attribute for keys prefixed with custom.. Values are coerced by dataType (a number that comes out NaN is skipped; a boolean is compared against "true" or true). Errors are swallowed here as well.
    • On a successful match, ApplyVerifiedAttribute merges the verified attribute into custom_attribute.
  11. 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

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) wires the formbuilder service, validation, profile mapping, the liff adapter, the storage adapter, the AMQP publisher, and the 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, and the question-type constants such as QEmail, QFileUpload, and QDbValidation

Connections to Other Services