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
- 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.
- 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
- 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).
- Style it — cover image, a 7-value color set, and the button label, with a live preview in the right column.
- Configure the thank-you page —
defaultmode (fixed content, every field read-only) orcustommode 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). - 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:
- Data source — pick a database from Member Database (required once the toggle is on). The list fetches only the first 100 databases.
- 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.
- 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).
- Record found — optionally pick a custom attribute to stamp, with the value to write (defaults to
4. Turn on OTP verification (optional)
- 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.
- 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.
- Exactly one verification field is allowed — pick a column of the member record (not a form question) and the delivery channel (SMS or email).
- 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). - The entire tab disappears for a user who cannot view
member-database.
5. Review responses and export
- Click the view-responses action from the form list.
- Review the 3 stat cards — Total / Complete / Incomplete. Click a card to filter the table.
- 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.
- 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_hashis derived from the form title via SHA-256, and it carries a database-wide unique index — the value ishex(sha256(title))[:12], andform_builder.form_hashhas aUNIQUE INDEXthat is not scoped toline_oa_idat all. Two consequences worth knowing:- Two different OAs cannot both use the same form title — the duplicate-title check (
existsByTitle) filters byline_oa_idand 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
deletewithout clearingform_hash, whileexistsByTitleexcludesdeleterows from its check, so the UI happily lets you re-create a form with the old title and it fails at INSERT. form_hashis not recomputed when the title changes — already-published links keep working, but the hash no longer corresponds to the new title.
- Two different OAs cannot both use the same form title — the duplicate-title check (
PUT /form-builder/:idwithout athemein the payload returns 500, every time — this is deliberate port fidelity. The original TS code dereferencedtheme.coverwith no guard, so anundefined/nulltheme 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 includetheme(the create path has the same trap when acoverImagearrives alongside an empty theme).thankYouis enforced as a closed shape, unlikethemewhich 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.urlmust use the https or line scheme only —httpis rejected, not upgraded; there is an allowlist that catchesjavascript:/data:even when smuggled with control characters, and URLs carrying userinfo (https://[email protected]/) are refused.action.urlis required when the type isurland must be null or absent for every other type (so a stalerich_menu_idcan't fire behind a close button). The same mutual-exclusion rule applies torich_menu_id.auto_close_secondsmust be between 3 and 60 seconds, and must always be null when the action isurlornone.messageruns 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_menurows matching theidand theline_oa_idof the form itself (on the update path that means the stored value, not the caller's OA), with statusactive. A miss returns a 404 that is deliberately indistinguishable from "no such menu". Additionally, if the form doesn't require LINE login, choosing therich_menuaction is rejected with a 400 — there is no lineUserId to switch a menu for. - Enabling OTP has an out-of-band guard —
validateOtpConfigGuardreads the caller's OAotp_configrow 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.otpis capped at one verification field on the backend, not just in the UI — the validator carries a literalfields may contain only one verification fieldrule, alongside "OTP enabled with zero fields" being a 400 ("an editor bug, not a valid state"). Every other key ofprofileMappingstill accepts anything — only theotpsubkey 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/webpand 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 withtheme.coverset to null rather than returning an error. - The list/response paths are tenant-scoped for real, but by different mechanisms —
GET /form-builder/:idfilters onid AND line_oa_id;DELETEandPUTrun 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 anullbody rather than a 404 (retained TSfindOnebehavior). Responses/stats/CSV, which are addressed byform_hash, are scoped by joining back toform_builderand filteringline_oa_id, becauseform_submissionitself carries no ownership column.
Related menus in this journey
- 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/emailConfiguredfrom it to decide what's selectable, and the backend re-checks it independently when the form is saved. - Rich menu — the thank-you
rich_menuaction must reference a rich menu belonging to the form's own OA with statusactive(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-masterdirectly. It readsGET /template-message/merge-tagsand keeps the keys prefixedcustom..
Things to watch out for
- A form title can't collide with another OA's (because
form_hashis 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=totalstraight to the API (not one of thecomplete/incompletevalues 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 theline-oa-managementsubject, notform-builderlike 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
PermissionGuardchecking theVIEWaction on theform-buildersubject, but the editor page checks theline-oa-managementsubject (see Things to watch out for). The frontend permission model only hasVIEWandEXPORTactions, 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 (
createonPOST /form-builder,updateonPUT /form-builder/:id), and every one of them runs through no-op middleware —internal/modules/auth/policy.gostates outright that API-level policy enforcement is intentionally disabled (PoliciesGuard.canActivatealways 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 forthankYouandprofileMapping.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 PermissionGuardsrc/components/form-builder/list/—form-builder.container.tsx,form-builder-filter.tsx,form-builder-table.tsxsrc/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 groupingssrc/components/form-builder/constants/theme-form.constants.ts— the 7 default colors and the cover-image rulessrc/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 extractioninternal/modules/formbuilder/service.go—form_hash, duplicate-title check, cross-OA ownership, thetheme500 path, CSV exportinternal/modules/formbuilder/dto.go— inbound validation rules and cover-image mime rulesinternal/modules/formbuilder/otp.go— the closed shape forprofileMapping.otpand theotp_configguardinternal/modules/formbuilder/thankyou.go— the closed shape forthankYou, the URL allowlist, the content deny-listinternal/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