Matching Respondents to the Customer Database
Overview
The mechanism that turns an ordinary form into a member-verification form. An admin picks one customer database (customer_database) and defines db_validation questions bound to columns in it via fieldKey. The respondent must match a single row on every criterion at once to count as a match.
On a successful match, the system tags the LINE user through custom_attribute and binds matchedRowId to the submission so the same record cannot be claimed twice.
Business Flow
ValidateSubmission(fb, answers, lineUserID)
- Read the config from
form_builder.profile_mapping. If it is disabled or fails to decode, skip everything (checked:false). - Collect criteria from the form definition only: walk the
db_validationquestions, take eachfieldKey(which must be a non-empty JSON string), pair it with the answer, and trim. Empty values are skipped. Crucially,fieldKeycomes from admin-controlled config rather than the user's body, which is what prevents SQL and JSONB key injection. Key order is preserved so the generated SQL is identical every time. - With no criteria at all, skip (
checked:false). - Verify that
databaseIdcoerces to a positive integer and that thecustomer_databaserow exists withstatus <> 'delete'. Failing either leads tohandleNotFound. - Issue a single query that requires every criterion to hold on the same row:
SELECT id FROM customer_database_row WHERE database_id = $1 AND data->>$2 = $3 AND data->>$4 = $5 ... LIMIT 1. Both keys and values are passed as parameters — no string interpolation anywhere. - On a match
- If
oneAccountPerRecordis enabled, look for aform_submissionwithis_submitted = truewhosemetadata->'profileMapping'->>'matchedRowId'equals this row. If one exists and belongs to a different LINE user (or to an unknown one), return 400 with{code:"RECORD_ALREADY_CLAIMED"}and the message fromalreadyClaimedMessage, defaulting toThis record is already linked to another LINE account. - Otherwise return
{checked:true, matched:true, matchedRowID}.
- If
- On no match,
handleNotFounddecides:- With
registerIfNotFound = true, pass as "unverified" ({checked:true, matched:false}), allowing new people to sign up. - Otherwise return 400 with
{code:"PROFILE_NOT_FOUND"}and the message fromnotFoundMessage, defaulting toRecord not found.
- With
ApplyVerifiedAttribute(fb, lineUserID)
Called best-effort after a successful save when a match was found.
- The key is
verifiedAttribute, defaulting toverified; acustom.prefix is stripped if present. - If
verifiedAttributeis deliberately set to an empty string (the admin chose not to tag anyone), nothing happens — but with no config at all it still defaults toverified, for parity. - Values of
"true"or"false"are converted to booleans; anything else is stored as a string. The default istrue. - The write merges via
custom_attribute = COALESCE(custom_attribute,'{}'::jsonb) || $2::jsonb.
Key Files & Functions
This feature exposes no routes of its own: it is step 6 of Submit and the first step of an OTP request.
| File | Functions |
|---|---|
internal/formsubmission/profilemapping.go | NewProfileMappingService(db), ValidateSubmission, ApplyVerifiedAttribute, handleNotFound, decodeProfileMapping, decodeDbValidationQuestions, jsonString, jsNumberInt, anyToString |
| Config structure | profileMappingConfig with enabled, databaseId, registerIfNotFound, notFoundMessage, verifiedAttribute, verifiedValue, oneAccountPerRecord, alreadyClaimedMessage |
| Error body | internal/formsubmission/errors.go → newCodeError for the PROFILE_NOT_FOUND and RECORD_ALREADY_CLAIMED codes |
Connections to Other Services
- The
customer_databaseandcustomer_database_rowtables (jsonbdata),form_submission(jsonbmetadata), andline_user(jsonbcustom_attribute). - Uses the sqlx pool directly with no separate repository, mirroring the raw queries in the source.
- The resulting
matchedRowIDis consumed by OTP Verification, both when resolving the destination phone number or email and when comparing against an already-verified session. - Called from Form Answer Submission.
- The related client-web feature is
form-fill, which keeps a lookup table translatingPROFILE_NOT_FOUNDandRECORD_ALREADY_CLAIMEDinto Thai messages.