Skip to main content

Form Filling (Form Builder)

Overview

The form filling page is where customers complete forms that admins designed in the CMS form builder. It supports more than 20 field types along with several higher-level capabilities: conditional display logic, per-form custom colour themes, mandatory LINE login, one-submission-per-user limits, and OTP identity verification.

The architecture separates concerns cleanly. The page loads the form definition and manages access state, then hands the form structure to a container component that renders each field according to its declared type.

Business Flow

  1. The user lands on /:hash/form/:id, where id is the form hash.
  2. The app fetches the form definition via GET /form-builder/:hash, receiving the title, description, question list, theme, login requirement, one-time submission flag, thank-you configuration, and profile mapping.
  3. The form theme is resolved and installed on the page as CSS variables (for example --form-page and --form-card), and the document title is set from the form name.
  4. If the form requires LINE login, the form stays blocked until LIFF is ready — preferring formLiffId and falling back to lineLiffId. Once ready, the ID token is retrieved and held in state.
  5. If the form allows only one submission, the app calls POST /form-builder/:hash/is-submitted with the LINE user ID. If a prior submission exists, the user is redirected to the thank-you page immediately. A loading screen is shown throughout this check so the form never flashes into view.
  6. Once the page has loaded, the app records an "opened but not submitted" event via POST /form-submission/:hash/form-submit-incompleted, which feeds drop-off reporting in the CMS.
  7. As the user types, changed values are held in state so conditional logic can be re-evaluated in real time.
  8. On submit, values are normalized before being sent, following the rules described below.
  9. If the form has OTP enabled, the process pauses and switches to the OTP verification step (see OTP Verification in Forms).
  10. The actual submission goes out as POST /form-submission/:hash in FormData form, with the x-liff-token header attached.
  11. On success, the user is taken to that form's thank-you page.
  12. The /:hash/form path without an id acts as a catch page: it reads the liff.state value LINE sends back and redirects to the correct form, preserving the original query parameters.

Value normalization rules

Field typeTransformation
File uploadSends the path of the successfully uploaded file
DateFormatted as YYYY-MM-DD to prevent timezone-induced day shifts
Date of birthCombines the separate day, month, and year values into YYYY-MM-DD
Single and multiple choiceWhen "other" is selected, the value is replaced with the text the user typed

After normalization, all helper keys ending in _other are stripped before sending.

Special error handling

  • When the API returns HTTP 400 with an error code meaning the profile was not found or the entitlement has already been claimed, the page shows the message the admin configured for that case.
  • When the response indicates the maximum number of submissions has been reached, the user is sent to the thank-you page rather than shown an error. The server enforces the real rule; the client only smooths the experience.

Key Screens & Components

Pages

  • The main form page (src/app/[hash]/form/[id]/page.tsx) handles data loading, access checks, and theme installation.
  • The redirect page (src/app/[hash]/form/page.tsx) receives liff.state and forwards the user to the destination form.

Container and shared parts

  • FormBuilderContainer (src/components/form-builder/layout/form.container.tsx) is the core: it manages submission, assembles the FormData payload, dispatches field rendering by type, and receives the OTP verification result.
  • The form header (form-header.tsx) renders the cover image, title, and description.
  • Loading and not-found states are separate components in the same folder.

Theming

  • The theme resolution module (src/components/form-builder/theme/resolve-theme.ts) determines the effective theme, converts it into a set of CSS variables, validates colour values, and supplies a default theme when none is configured.

Field components

All field components live under src/components/form-builder/, covering single-line and multi-line text, URL input, terms acceptance, dividers, custom inputs, single and multiple choice, single-select and multi-select dropdowns, date pickers, date-of-birth pickers, and file upload.

Hooks and services

  • Page-scoped hooks (src/app/[hash]/form/[id]/hooks/) handle fetching the form definition, checking for a prior submission, and recording the incomplete-open event.
  • The conditional logic evaluator (src/utils/logic.utils.ts) supports multiple conditions combined with AND or OR.
  • The form builder service and form submission service handle API access, while type definitions and the field-type enum live under src/service/types/ and src/service/enums/.

Endpoints used

MethodPathPurpose
GET/form-builder/:hashFetch the form definition
POST/form-builder/:hash/is-submittedCheck whether the user already submitted
POST/form-submission/:hashSubmit answers
POST/form-submission/:hash/form-submit-incompletedRecord that the form was opened

Dependencies

  • Ant Design Form underpins the entire container: form instance management, field binding, and validation rules.
  • dayjs powers the date fields and pre-submission date formatting.
  • LINE Login via LIFF — forms prefer formLiffId and fall back to lineLiffId.
  • File/Image Upload for the file upload field type.
  • OTP Verification in Forms when profile mapping enables OTP.
  • Post-submission Thank-you Page is the destination after a successful submission.
  • Unit tests cover the duplicate-submission check hook and the theme resolution module.

Backend Details (Client API)

The form submission pipeline is the longest and most complex flow in the whole client-api service: several deliberately ordered validation gates, followed by a set of side effects after a successful save. This section describes what actually happens once the web app presses submit.

Loading the form definition (GET /api/form-builder/:hash)

  • The active form is looked up by hash. Not found returns 400, not 404, with only the bare message Bad Request (parity with the legacy system, which threw without a message). The web app must therefore read a 400 from this endpoint as "form not found", not "you sent bad data".
  • The theme cover image is converted from a storage path into a public URL, and the backend first checks that the object actually exists. If the file is missing or storage is not configured, the value is written as null — so the web app never receives a URL pointing at a nonexistent file, and must always handle a null cover.
  • Merging common rules is what makes client-side validation possible: admins define reusable rules in the CMS (Thai mobile number format, national ID, employee code) holding a regex, min/max length, and error messages in both Thai and English. When the form loads, the backend fetches the active rules and attaches a commonRule key onto each question bound to one, so the web app has the regex ready to validate locally.
  • On this path the common rules are read live from the database with no cache, on purpose — with a cache, an admin editing a rule would leave forms using the stale version. (The separate GET /api/form-builder-rule endpoint is cached, but forms do not use it.)
  • The backend preserves the original key order of the theme and question objects (ordered JSON), so results can be diffed byte-for-byte against the legacy system.

The "already submitted?" check (POST /api/form-builder/:hash/is-submitted)

There is an important security point here for the web app:

  • The backend does not trust the lineUserId in the request body at all — it is discarded entirely. The user is identified only by verifying x-liff-token. If the body were trusted, anyone could probe which LINE user had already submitted a given form, turning the endpoint into a per-user oracle.
  • If verification fails or no token is present → it returns false rather than an error. The user sees an empty form as though nothing was submitted, while the real duplicate protection still runs at submit time.
  • The response is a bare text boolean ("true"/"false") with status 201, not 200, because it is a POST — parity with the legacy framework's default.
  • A POST /api/form-builder/:hash/form-submission endpoint also still exists but is a no-op stub (it simply echoes the hash back), ported for parity only. Do not call it.

Saving a draft (POST /api/form-submission/:hash/form-submit-incompleted)

  • If the form requires LINE login but token verification fails → 401 Authentication required for this form.
  • If a draft row already exists for this user, the existing row is returned rather than a duplicate created — so a user reopening the form repeatedly does not skew funnel numbers.

The order of checks on a real submission (POST /api/form-submission/:hash)

This order matters, because each step gates the next — the error the web app receives tells you which gate it hit. The backend accepts multipart, urlencoded, and JSON bodies.

  1. Load the form → verify the token → enforce the login requirement.
  2. Strip lineUserId from the body (only the token's value is used, as above) and extract otpRef, which is not an answer to a question.
  3. Match each answer to its question type from the form definition.
  4. Reject unknown question ids — any key that matches no question in the form fails the whole request with 400 and a list of the offending ids.
  5. Validate the answers against regex and length rules (details below).
  6. Profile mapping — match the respondent against the customer database.
  7. The OTP gate — only for forms with OTP enabled.
  8. Move attachments from temporary storage to permanent storage.
  9. Save, applying the duplicate-submission rule.
  10. Run the side effects.

An error shape that differs from the norm (essential for correct error handling)

This pipeline returns two different error shapes:

  • Ordinary errors arrive in the standard envelope {statusCode, message, error}.
  • But validation, profile mapping, and OTP errors arrive as raw objects, not the envelope — for example {message: "Form validation failed", errors: [...], details: "..."}, or {code: "PROFILE_NOT_FOUND"} / {code: "RECORD_ALREADY_CLAIMED"} / {code: "OTP_REQUIRED"}, all with status 400.
  • This is ported behaviour from the legacy system (an exception thrown with an object payload). The web app must therefore read code straight off the body rather than from an envelope message.

The server-side validation engine (the real gate, not a UI hint)

Client-side validation helps UX, but a hand-crafted request can bypass it. The real gate is the backend, whose behaviour is worth knowing:

  • Only submitted answers are checked — a field marked required whose key is not sent at all is never validated (parity with the legacy system). Required-ness therefore effectively depends on the web app. This is a known limitation, not something that surfaces as an error.
  • Non-required questions with empty values are skipped; required ones that are empty return This field is required.
  • commonRule.pattern takes precedence over the per-type rules — when a question is bound to a common rule, that regex replaces the type's default, the common rule's length limits apply, and the error message is chosen Thai → English → default.
  • Default per-type rules (used when no common rule applies): email; phone (10–15 digits); digits only; URL; 13-digit national ID; date in YYYY-MM-DD; single-line text 1–255 characters; long text 1–5000 characters.
  • Date of birth is not only format-checked: the age is computed and compared against the minimum age the admin configured, returning a message stating the required minimum years.
  • Terms acceptance must be genuinely true, otherwise You must agree to the terms and conditions.
  • File upload fields always pass this stage (file checks belong to the separate upload step).
  • All choice types (single choice, multiple choice, dropdown, multi-select) have validation disabled — the backend does not check that a submitted value is one of the offered options. This is parity with the legacy system and worth knowing, because off-list values will be stored.
  • Length is counted in UTF-16 code units (like JavaScript's String.length), not bytes, so Thai text passes or fails exactly as it did before.
  • Important note: some regexes JavaScript accepts (lookahead, lookbehind, backreferences) cannot be compiled by Go. If an admin configures such a pattern, the user sees Invalid validation pattern configured for this field, which is a configuration error, not the respondent's fault — the fix is to correct the rule in the CMS, not to ask the user to retype.

Profile mapping — when a form becomes a member-verification form

An admin can bind a form to one customer database and define "check against database" questions mapped to columns in it. The respondent must match a single row on every criterion at once to count as matched (executed as one query requiring all conditions on the same row).

  • Security note: the column names used for lookup come only from admin configuration, never from the user's body, and both keys and values are passed as query parameters with no string interpolation — direct protection against SQL/JSONB key injection.
  • Submitted values are trimmed before comparison, and empty values are skipped.
  • No match splits two ways by configuration: if the admin enabled "allow new registrations", the submission proceeds as "unverified" with no error; otherwise it returns 400 {code: "PROFILE_NOT_FOUND"} with the admin's custom message (or the default).
  • Matched but already claimed: if "one account per record" is enabled, the backend looks for an existing successful submission bound to that row. If one exists under a different LINE account → 400 {code: "RECORD_ALREADY_CLAIMED"}.
  • A deleted customer database is treated as "no match" (the PROFILE_NOT_FOUND path) rather than raising a distinct error.
  • On a successful match and save, the backend tags the LINE user by merging a value into their custom attributes (default key verified). If the admin deliberately sets the key to an empty string, no tag is written.
  • The matched row id is stored in the submission's metadata and is the value used to block duplicate claims on subsequent submissions.

The OTP gate (for forms with OTP enabled)

  • The backend never trusts a client-side "verified" flag. A genuinely verified OTP session must exist server-side.
  • Beyond that, the session's matched row id must equal the row matched during this submission, otherwise 400 {code: "OTP_REQUIRED"} — preventing an OTP verified against one record from being reused for another.
  • See OTP Verification in Forms for details.

Attachment relocation and the duplicate-submission rule

  • File-upload answers still sitting in the temporary folder are copied into the form's permanent storage location, with filenames restricted to letters, digits, dots, underscores, and hyphens. Paths not under the temporary folder are silently skipped (no error).
  • The duplicate-submission rule applies at save time: if a draft row exists it is updated; otherwise the number of successful submissions is counted, and if the form is set to one-time submission with a count above zero → 400 with the "maximum number of submissions" message (which the web app turns into a redirect to the thank-you page).
  • Parity note: the response counter on the form itself (responses_count) is never incremented — that number is not trustworthy. Real counts must come from the submission table.

Side effects after a successful save

  • Converting a guest into a member (when the form is configured for it): a rich-menu switch job is published, and critically, if publishing that job fails the whole request fails — unlike the other side effects, which swallow errors. The user's type is also updated to member on a best-effort basis.
  • Switching the rich menu per the thank-you action (when configured): published purely best-effort; a failure does not affect the submission result.
  • Writing the profile back to the LINE user: answers of type first name, last name, email, and phone are written to the user's columns. National ID and date of birth are deliberately discarded because no columns exist for them (the legacy system ignored them too). All errors in this step are swallowed.
  • Field attribute mappings: admins can direct any answer into a user column (restricted to an allowlist: display name, first name, last name, email, mobile number, language) and/or merge it into custom attributes, coercing types as configured (values that cannot be coerced to a number are skipped). All errors are swallowed.
  • The consequence of all this swallowing is that the user always sees a successful submission, even if part of the profile update failed. When user data does not update as expected, the answer is in the backend logs, not the response.
  • On success the backend returns the saved answers, the form information, and the message Form submitted successfully with status 201.