Skip to main content

Form builder

Overview​

Form builder is the tool for building an online form (think Google Form), publishing it as a link for LINE users to fill in, then reviewing responses, statistics, and CSV exports inside the CMS. It fits event registration, surveys, and identity-check forms gating a reward.

What sets this menu apart from a generic form tool is Profile Mapping — wiring the form to a member database from the Member Database menu so the form looks up a member record from the answers submitted, then decides whether to accept or reject the submission, and (when enabled) sends an OTP to the phone/email on the matched record, never to whatever the filler typed in.

Who uses it — marketing or customer-care staff who need to collect data from LINE users (this module carries a real, enforced ModuleGate on the backend, and is one of the most heavily self-validated modules in the system — see Access control below).

User journey​

1. Review the form list​

Open the Form builder menu. The list has no tabs — just two filters: free-text search by name/keyword, and a status Select. That Select offers only active / inactive / draft, even though the backend also knows a delete status, because deleting is a soft delete that flips the status to delete. Sorted newest-created first, and the filter state is remembered in sessionStorage.

The table has 7 columns — Title, Description, Link (with copy-to-clipboard), Created date (sortable), Status, Responses, and edit / view-responses / delete actions.

2. Create a form​

  1. Click create, then enter a form title (max 200 characters) and description (max 250). The title must not duplicate another form in the same OA.
  2. Add up to 10 questions — headers (Header 1 / Header 2) and dividers don't count toward that cap and can be added without limit. There are 22 question types in total, grouped as:
    • Plain inputs — short text, paragraph, number, date, URL, file upload, custom-pattern input
    • Personal data — first name, last name, email, phone, citizen ID, date of birth — these 6 are limited to one each per form
    • Choices — single choice, multiple choice, dropdown, multi-select dropdown, terms acceptance
    • DB Validation — this button stays locked until Profile Mapping is on with a database selected; once unlocked it is hoisted to the first position
  3. Set conditional display (e.g. show question B when question A answers …) — the parent question, operator, and value must all be filled (only the "is not empty" operator is exempt from needing a value).
  4. Style it — cover image, a 7-value color set, and the button label, with a live preview in the right column.
  5. Configure the thank-you page — default mode (fixed content, every field read-only) or custom mode with 5 action types (close the window / open a link / go to the OA chat / switch a rich menu from the Rich menu menu / no button).
  6. Save as draft or publish as active — once published you cannot go back to draft (the "Draft" option disappears from the radio group as soon as the current status isn't draft), but active ↔ inactive stays freely switchable.

3. Wire it to Member Database (Profile Mapping)​

The settings panel is explicitly staged in three steps:

  1. Data source — pick a database from Member Database (required once the toggle is on). The list fetches only the first 100 databases.
  2. Match fields — the system lists the selected database's columns; each column offers "Add input", which auto-creates a DB Validation question for it. If no column is mapped at all, a warning appears: the form will accept anyone.
  3. Outcomes — define both cases:
    • Record found — optionally pick a custom attribute to stamp, with the value to write (defaults to true), plus an optional "one LINE account per record" switch with a message shown when the record is already claimed.
    • No record found — two choices: reject the submission (show your message, save nothing) or register as a new member (accept the data, but never write the verified attribute).

4. Turn on OTP verification (optional)​

  1. The Verification tab only allows the switch when Profile Mapping is on, a database is selected, and that database has columns. If any prerequisite disappears mid-edit, the system disables the switch for you.
  2. This tab reads channel readiness from Settings > OTP Verification — a channel that is still "not configured" is disabled and raises a warning that links to the settings page. If neither channel is ready, the add-verification-field button is unclickable.
  3. Exactly one verification field is allowed — pick a column of the member record (not a form question) and the delivery channel (SMS or email).
  4. Four copy overrides are available for the OTP screen — heading (80 chars), description (put {destination} in it to insert the masked contact), verify button label (40), resend label (40).
  5. The entire tab disappears for a user who cannot view member-database.

5. Review responses and export​

  1. Click the view-responses action from the form list.
  2. Review the 3 stat cards — Total / Complete / Incomplete. Click a card to filter the table.
  3. The table has 3 pinned-left columns (submit time, submitted yes/no, copyable LINE User ID), then one auto-generated column per question in the form (headers/dividers excluded). File-upload answers show a "View file" link that opens PDFs/images in a modal.
  4. Export to CSV — the file is prefixed with a UTF-8 BOM so Excel renders Thai correctly, and the filename is generated on the frontend as form-responses-<formHash>-<timestamp>.csv.

Key details verified against the source​

Confirmed from internal/modules/formbuilder/{controller,service,dto,otp,thankyou,contrast}.go in line-management-cms-api-go:

  • form_hash is derived from the form title via SHA-256, and it carries a database-wide unique index — the value is hex(sha256(title))[:12], and form_builder.form_hash has a UNIQUE INDEX that is not scoped to line_oa_id at all. Two consequences worth knowing:
    • Two different OAs cannot both use the same form title — the duplicate-title check (existsByTitle) filters by line_oa_id and therefore passes, but the INSERT then collides with the unique index and surfaces as a 500, not the readable "Title is already exist" message.
    • A deleted form still reserves its title — a soft delete flips the status to delete without clearing form_hash, while existsByTitle excludes delete rows from its check, so the UI happily lets you re-create a form with the old title and it fails at INSERT.
    • form_hash is not recomputed when the title changes — already-published links keep working, but the hash no longer corresponds to the new title.
  • PUT /form-builder/:id without a theme in the payload returns 500, every time — this is deliberate port fidelity. The original TS code dereferenced theme.cover with no guard, so an undefined/null theme threw a TypeError, which isn't an HttpException, so Nest returned 500. The Go port reproduces that exact 500 (errThemeCoverTypeError). The real UI always sends a theme so it never hits this, but a direct API call must always include theme (the create path has the same trap when a coverImage arrives alongside an empty theme).
  • thankYou is enforced as a closed shape, unlike theme which accepts anything — an unknown key at any level is a 400, because this blob drives navigation and a rich-menu switch. Real, enforced rules include:
    • action.url must use the https or line scheme only — http is rejected, not upgraded; there is an allowlist that catches javascript:/data: even when smuggled with control characters, and URLs carrying userinfo (https://[email protected]/) are refused.
    • action.url is required when the type is url and must be null or absent for every other type (so a stale rich_menu_id can't fire behind a close button). The same mutual-exclusion rule applies to rich_menu_id.
    • auto_close_seconds must be between 3 and 60 seconds, and must always be null when the action is url or none.
    • message runs through a deny-list of hostile tags/schemes (<script, <iframe, javascript:, srcdoc=, on…= event handlers, and more) — the source comment says outright that this is not a sanitizer; the real guarantee comes from client-web rendering the field through TiptapViewer.
  • The thank-you rich-menu selection gets a real ownership check against the database — the system counts rich_menu rows matching the id and the line_oa_id of the form itself (on the update path that means the stored value, not the caller's OA), with status active. A miss returns a 404 that is deliberately indistinguishable from "no such menu". Additionally, if the form doesn't require LINE login, choosing the rich_menu action is rejected with a 400 — there is no lineUserId to switch a menu for.
  • Enabling OTP has an out-of-band guard — validateOtpConfigGuard reads the caller's OA otp_config row directly on every create/update and returns a 400 pointing at the OTP settings page if the channel the form wants isn't ready. Calling the API directly to bypass the UI cannot turn OTP on.
  • profileMapping.otp is capped at one verification field on the backend, not just in the UI — the validator carries a literal fields may contain only one verification field rule, alongside "OTP enabled with zero fields" being a 400 ("an editor bug, not a valid state"). Every other key of profileMapping still accepts anything — only the otp subkey has a locked shape.
  • WCAG color checking is fully written but deliberately not switched on for saves — the backend only verifies that the two new keys (primaryColor, secondaryColor) are genuine hex colors (#RGB/#RRGGBB/#RRGGBBAA). The table of 5 minimum-contrast pairs (text on background 4.5:1, button label on button 3:1, and so on) has the full arithmetic in place but does not block saving, because the 5 legacy color keys were never format-constrained; turning it on abruptly would make every existing form unsavable. The warnings surface as inline alerts in the CMS instead, in front of the person who picked the colors.
  • Cover-image type checking uses magic bytes, not the filename — the backend accepts image/jpeg, image/png, image/gif, image/webp and reads the file header to derive the real extension, while the frontend accepts only JPG/PNG and adds its own rules (under 10 MB, at least 1024px wide, 4:1 aspect with a crop tool). GIF/WebP therefore upload fine through the API but not through the UI — and when the upload fails, the system saves the form anyway with theme.cover set to null rather than returning an error.
  • The list/response paths are tenant-scoped for real, but by different mechanisms — GET /form-builder/:id filters on id AND line_oa_id; DELETE and PUT run a separate ownership check first and answer 404/400 for someone else's form. But both paths, for an id that doesn't exist at all, return HTTP 200 with a null body rather than a 404 (retained TS findOne behavior). Responses/stats/CSV, which are addressed by form_hash, are scoped by joining back to form_builder and filtering line_oa_id, because form_submission itself carries no ownership column.
  • Member Database — a hard prerequisite for Profile Mapping, and indirectly for OTP too (Profile Mapping off means the Verification tab can't be enabled). It is also the source of the "record columns" OTP sends codes to.
  • Settings > OTP Verification — the layer underneath that decides which channels this OA can send OTP on. The Verification tab reads smsConfigured/emailConfigured from it to decide what's selectable, and the backend re-checks it independently when the form is saved.
  • Rich menu — the thank-you rich_menu action must reference a rich menu belonging to the form's own OA with status active (the frontend fetches only the first 100).
  • Segment audience — form responses can be used as a target-group condition there (verified: that menu has a dedicated form-builder condition section).
  • Attribute Setup — the source of the custom attributes used as Field Mapping targets and as Profile Mapping's "verified" attribute — with one caveat worth knowing: Form builder's field-mapping screen does not read /attribute-master directly. It reads GET /template-message/merge-tags and keeps the keys prefixed custom..

Things to watch out for​

  • A form title can't collide with another OA's (because form_hash is unique database-wide), and a deleted form still reserves its title — both produce an opaque 500. Prefer titles that are specific to your own OA.
  • Restrictions that exist only on the frontend, with no backend re-check at all: the 10-question cap, the 200-char title / 250-char description / 200-char question-title limits, the "one of each personal-data type" rule, the "at least one question and at least one required question" rule, and every cover-image size/aspect rule. A direct API call bypasses all of them.
  • The "you can add up to 10 options" hint on a choice question is display copy only — the Add-option button is never disabled at the 10th option, so more are genuinely creatable.
  • Published can't return to draft — check everything before publishing. To stop accepting responses temporarily, switch to inactive instead.
  • Clicking the "Total" stat card does not clear the filter; it sends submitStatus=total straight to the API (not one of the complete/incomplete values the backend knows). The result happens to be unfiltered anyway, because the backend falls through to its default branch.
  • The form editor page (/form-builder/form) checks permission against the line-oa-management subject, not form-builder like the list and response pages — an inconsistency to keep in mind when assigning permissions.
  • Deleting from the list is a soft delete, and any error during that delete is swallowed silently (an empty catch). If the form doesn't disappear from the table, refresh and check again.

Access control (verified against the source)​

  • Frontend — the list and response pages are wrapped in a PermissionGuard checking the VIEW action on the form-builder subject, but the editor page checks the line-oa-management subject (see Things to watch out for). The frontend permission model only has VIEW and EXPORT actions, so there is no create/update/delete granularity at the UI level.
  • Backend — all 9 routes in this module are wrapped in a real, enforced ModuleGate("form-builder"). If a platform admin closes this module for an organization, customer-type users in that org get a 403 immediately even when calling the API directly (onemoby-type callers always bypass the gate, and the gate is designed to fail open — a lookup failure allows the request rather than denying it).
  • Action-level policy annotations exist on only 2 routes (create on POST /form-builder, update on PUT /form-builder/:id), and every one of them runs through no-op middleware — internal/modules/auth/policy.go states outright that API-level policy enforcement is intentionally disabled (PoliciesGuard.canActivate always returned true in the TS original). Permissions only drive menu visibility in the web app.
  • A correction to the earlier technical documentation — the old write-up described Form builder as "the strictest-enforced module in the whole system," which isn't literally true: 15 modules currently carry a ModuleGate (campaign, rich-message, rich-menu, audiences, workflow, attribute-master, customer-database, import-mapping, auto-response, friend-track, template-message, api-key, trigger-rule, report-all-friends, and form-builder), and attribute-master annotates action-level policies on all 10 of its routes — more than form-builder's 2. What actually makes Form builder strict is in-service validation, not middleware — it does its own cross-OA ownership check (answering 404, not 403), a separate ownership query for the rich menu, the out-of-band OTP guard, a URL-scheme allowlist, a content deny-list, and closed-shape validators for thankYou and profileMapping.otp. No other module in the system does that full set.
  • No route in this module is @Public() — every one sits behind the global JwtAuthGuard.

Source file reference​

Frontend (cms-web-2026)

  • src/app/form-builder/page.tsx, src/app/form-builder/form/page.tsx, src/app/form-builder/response/page.tsx — entry points and each page's PermissionGuard
  • src/components/form-builder/list/ — form-builder.container.tsx, form-builder-filter.tsx, form-builder-table.tsx
  • src/components/form-builder/form/ — form-builder-form.tsx (the full pre-save validation set), hooks/useFormBuilder.ts, hooks/useQuestionOperations.ts (question-count rules), components/ProfileMappingSettings.tsx, verification/verification-form.tsx (the OTP tab), thank-you-form/, theme-form/form-colors-section.tsx (contrast warnings)
  • src/components/form-builder/form/constants/form-builder.constants.ts — MAX_QUESTIONS, question-type groupings
  • src/components/form-builder/constants/theme-form.constants.ts — the 7 default colors and the cover-image rules
  • src/components/form-builder/response/ — stat cards, response table, CSV export

Backend (line-management-cms-api-go)

  • internal/modules/formbuilder/controller.go — all 9 routes, ModuleGate("form-builder"), multipart body extraction
  • internal/modules/formbuilder/service.go — form_hash, duplicate-title check, cross-OA ownership, the theme 500 path, CSV export
  • internal/modules/formbuilder/dto.go — inbound validation rules and cover-image mime rules
  • internal/modules/formbuilder/otp.go — the closed shape for profileMapping.otp and the otp_config guard
  • internal/modules/formbuilder/thankyou.go — the closed shape for thankYou, the URL allowlist, the content deny-list
  • internal/modules/formbuilder/contrast.go — WCAG arithmetic, default colors, and why contrast isn't enforced yet

See the documentation team's earlier technical write-up at Form Builder and responses