Member Database
Overview
Member Database is a store of member data an admin uploads from a CSV file (out of a CRM, out of Excel) to serve as a standalone reference table for the Form builder menu to check whether someone filling in a form is a real member.
The thing that separates this menu from Import Data: Member Database never writes anything onto a LINE user's profile. What you upload lives in its own tables (customer_database + customer_database_row), and no module in the CMS API takes those rows and writes them onto line_user — unlike Import Data, which updates friend fields directly.
Who uses it — CMS admins who already hold a member list in another system and want to use it as an identity-verification condition on a form.
User journey
1. Review the database list
- Open the Member Database menu. The table shows Name (with its description underneath), Fields, Rows, Mapped forms, Created date, plus edit/delete buttons.
- Click the number in the Fields column to open a popover listing every column name as a tag.
- The Mapped column lists the title of every form bound to this database, or a "Not mapped" tag when there are none.
- The search box above searches both name and description. The list is always sorted newest-first by creation date.
2. Upload a new database
- Press Upload database to enter a 3-step wizard.
- Step 1 — Upload CSV. Drag in a
.csvfile (one file only). It goes to the backend to be read as a preview — nothing is saved to the database yet. - Step 2 — Preview. You see an "n fields, m rows" summary, every column name as a tag, and a table of the first 5 rows. If the file has problems, a red box lists every one of them and the Next button is disabled — you have to go back and pick a different file.
- Step 3 — Details. Enter a name (required, max 255 characters) and a description (optional, max 1,000 characters), then press Create.
3. Edit or replace the file
- Press the edit button in the table. If this database already has forms bound to it, a yellow warning box states the count and every form's title, warning that replacing the CSV will affect them.
- You can edit just the name/description without attaching a new file — the existing rows are left untouched.
- Or attach a new CSV to replace every column and row (the same preview validation applies as on create, and Save is disabled if the preview has errors).
4. Delete a database
- Press the delete button; a confirmation dialog opens naming the database.
- If the database still has forms bound to it, the delete is rejected and the server's message names the bound forms. The binding has to be removed on the form first.
5. Put it to work in Form builder
- Open a form in Form builder, turn on the Profile Mapping switch, and pick a database from this menu.
- On a Database validation question, choose which column of the database that question compares against.
- If the OTP verification step is enabled, pick a column of the matched record as the delivery channel for the code (e.g. a
phonecolumn) — that column doesn't have to be a question in the form. - Bindings show back up in the Mapped column of this menu's table.
Key details verified against the source
Confirmed from internal/modules/customerdatabase/{controller,service,dto,papaparse,papaparse_config}.go in line-management-cms-api-go:
- A single-column CSV can never be uploaded. This module's CSV reader replicates papaparse's behavior in full, including the automatic delimiter guess, which only accepts a delimiter when the average field count per line is greater than 1.99. A one-column file therefore has no candidate delimiter that qualifies; the parse records the error "Unable to auto-detect delimiting character; defaulted to ','", and any error at all disables the Next button.
- The real limits are 10 MB and 50,000 rows — both are checked on the backend and surfaced as preview errors (
File exceeds the 10MB size limit,CSV exceeds the 50000 row limit). Note the frontend does not check file size before sending, so a too-large file has to be uploaded in full before you find out. - "Has errors" changes what the preview's numbers mean. With no errors,
rowCountis the number of rows that will actually be stored and 5 sample rows come back. With errors, the row list is emptied and the samples disappear, butrowCountswitches to reporting the raw parsed row count instead — so a failed preview legitimately shows a row number with no sample table beneath it. - The header row is checked in three separate ways — a header must exist (
CSV file has no header row), no header cell may be empty (CSV header contains empty cell(s)), and no column name may repeat (CSV header contains duplicate cell(s): ..., listing the duplicates in order of first appearance). All three run against the raw header, not the auto-renamed one. - Uneven field counts are reported per line — e.g.
Row 12: Too many fields: expected 5 fields but parsed 6, which makes the file easy to fix. But since a single error disables Next, one malformed line blocks the whole file. - Every value is stored as plain text — the reader has dynamic typing off, so numbers and dates are stored as the strings that were typed. Every cell is trimmed, and missing cells become an empty string
"". The CSV's column order is preserved, both in the API response and in the jsonb persisted to the database. - The file is read twice, and the preview is not reused. The preview step (
POST /customer-database/preview) just parses and hands the result back, storing nothing. On Create the frontend re-uploads the same file and the backend parses it again — if the source file changed in between, nothing detects the difference. - Row inserts happen in one transaction, batched 1,000 at a time — on create and on file replacement alike. A mid-way failure rolls the whole thing back; there is no "half-uploaded file" state.
- Replacing the file is a delete-and-rewrite, not a merge. Columns and row count are overwritten, and every existing row is deleted before the new set goes in — no merging of old and new data, and no version history.
- Editing a database that has forms bound to it "works"; deleting one doesn't. The edit path has no guard at all (the frontend only warns), while the delete path checks for real on the backend and answers 400
Database is mapped by form(s): <titles>while any binding remains. - Finding "which forms are bound" is a direct JSON read — the query compares
form_builder.profile_mapping ->> 'databaseId'against the database id as a string, and excludes forms with statusdelete. A deleted form therefore doesn't block deleting the database. - Deletion is a status-based soft delete, not a deleted-at column. The record's
statusflips todeleteand every query in this module filters withstatus != 'delete'(there is nodeleted_date IS NULLcondition anywhere). The rows incustomer_database_roware not deleted along with it — they stay in the database, just unreachable through the API. - A per-record detail endpoint exists but the frontend never calls it.
GET /customer-database/:idreturns the same data as the table row plus the first 5 rows (ordered by ascendingid), but the list page has no route into a detail view — so what a user can actually inspect is the table and the column-name popover, nothing more. - The
:idparameter isn't converted to a number first — the controller passes the raw string straight into the query as in the original, so calling with a non-numeric id makes Postgres raise, surfacing as a 500 rather than a 400. - Sorting and pagination offer more than the frontend uses — the backend accepts
orderBy(defaultcreatedAt) andorder(defaultDESC), mapping property names onto real column names, and passing alimitthat isn't greater than 0 disables pagination entirely, returning every row. The frontend never sendsorderBy/order, hence the permanent newest-first ordering. - The 255-character name cap and 1,000-character description cap are frontend-only — the backend only checks that the name is a non-empty string (after trimming) and that description is a string when present; it never validates length.
- On edit, the frontend always sends the description as an empty string when the user clears it, never null — so the stored value becomes
""rather than NULL, which is why a "removed" description still counts as a present value for thedescription ILIKEsearch. - List search covers both name and description — the
name ILIKEORdescription ILIKEpair is wrapped in a single bracket and always ANDed with the OA scope andstatus != 'delete'. - The API response distinguishes "no description was sent" from "description is null" — when create is called without a description, the
descriptionkey is omitted from the JSON entirely, whereas reads from the database include the key with anullvalue (behavior preserved verbatim from the original).
Related menus in this journey
- Form builder — the main and only consumer of this menu. The link is the
profile_mapping.databaseIdvalue on the form record, and the database's columns are used in two places: (1) as the "database field" options on a Database validation question, and (2) as the OTP delivery channel for the matched record. The verification step can only be enabled when Profile Mapping is on, a database is selected, and that database exposes at least one column. - Settings > OTP Config — to use the OTP verification step on a form bound to this database, the OA's SMS or email channel must be configured first, or the channel options stay disabled.
- Import Data — a completely different mechanism, not just a different menu. See the comparison below.
How Member Database differs from Import Data
Every difference below is verifiable in the source of both modules:
| Member Database | Import Data | |
|---|---|---|
| Destination tables | customer_database / customer_database_row (one jsonb per row) | line_user (profile fields + custom_attribute) |
| How identity works | None — every row is stored as it appears in the file | A match key must be chosen (LINE UID / phone / email / custom attribute) and matched to a CSV column |
| Effect on friend profiles | None at all | Overwrites existing values on matched friends |
| Field mapping | None — column names are used as field names verbatim | Every column must be mapped to a target by hand |
| Processing | Immediate, in a single request (transactional) | Queued to RabbitMQ for a worker to run in the background |
| Unusable rows | Cause the whole file to be rejected | Skipped individually and summarized in a log file |
| Deletable | Yes (soft delete), unless a form is bound to it | No delete at all — kept as import-job history |
Put as briefly as possible: Member Database adds a reference table alongside the friend data without touching it, while Import Data changes the friend data itself.
Things to watch out for
- Replacing the CSV with a file whose column names differ silently breaks any bound form. Database validation questions reference columns by name, and so does the OTP delivery column, and nothing checks back whether the names a form uses still exist in the new file. Always compare column names before replacing a file.
- A single-column CSV can't be used with this menu at all — there must be at least two columns for the delimiter guess to succeed.
- One malformed line blocks the whole file; the preview lists them line by line so they can be fixed.
- File size isn't checked before sending, so a file over 10 MB gets uploaded in full before you learn it was rejected.
- Replacing a file has no version history and no undo — the previous dataset is gone the moment the save succeeds, so keep your own copy of the source file.
- Numbers and dates are stored as the text that was typed, so form-side matching is text matching. Keep formats consistent in the file (a phone number with dashes and one without are different values).
- There's no separate detail page and no export-back-to-CSV — uploaded data can only be checked via the row/field counts in the table and the column-name popover.
- A database with forms bound to it can't be deleted; unbind it in Form builder first (make sure the "Mapped" column reads "Not mapped" before deleting).
Access control (verified against the source)
- Frontend — wrapped in a
PermissionGuardchecking theVIEWaction on themember-databasesubject. - Backend — all 6 routes are wrapped in a real, enforced
ModuleGate(d, "customer-database"):POST /customer-database/preview,POST /customer-database,GET /customer-database,GET /customer-database/:id,PUT /customer-database/:id,DELETE /customer-database/:id. If a platform admin closes this module for an organization, customer-type users in that org get a 403 immediately even calling the API directly (onemoby-type callers always bypass the gate). - The backend module name and the frontend permission subject are not the same string — the frontend uses
member-databasewhile ModuleGate usescustomer-database(matchingsystem_module.nameand theinternal/modules/customerdatabase/directory). Both refer to the same feature but are configured in different places — when investigating "why can't I get in", check both names. - Every request is scoped by the
line_oa_idfrom context — another OA's databases are invisible and unmodifiable even if you know the id (you get 404 "Customer database not found"). - Action-level policy annotations (
create/read/readAll/update/delete) are declared on every route but run through the same no-op middleware as other modules — the real enforcement is the module-level ModuleGate. - No route in this module is public, and there is no hard-delete route — every deletion is a soft delete.
Source file reference
Frontend (cms-web-2026)
src/app/member-database/page.tsx— entry point andPermissionGuardsrc/components/member-database/member-database.container.tsx— list fetching, pagination, delete confirmationsrc/components/member-database/member-database-table.tsx— every column, the column-name popover, the "Mapped" tagssrc/components/member-database/upload-database-modal.tsx— the 3-step wizard and the condition that disables Next on preview errorssrc/components/member-database/edit-database-modal.tsx— the bound-forms warning box and the empty-string description behaviorsrc/components/member-database/csv-preview-detail.tsx— preview rendering and the error listsrc/components/form-builder/form/components/SpecialFields.tsx,src/components/form-builder/form/verification/verification-form.tsx— where the database's columns are consumed inside a form
Backend (line-management-cms-api-go)
internal/modules/customerdatabase/controller.go— 6 routes plusModuleGate("customer-database")and the multipart readinginternal/modules/customerdatabase/service.go— the 10 MB / 50,000-row limits, the 1,000-row insert batching, the bound-forms check, the status-based soft deleteinternal/modules/customerdatabase/papaparse.go,papaparse_config.go— the delimiter guess (the > 1.99 threshold) and the header / field-count checksinternal/modules/customerdatabase/dto.go— inbound validation for all three file-accepting routesinternal/modules/customerdatabase/orderedrow.go,orderedmap.go— preservation of the CSV's original column order
See the documentation team's earlier technical write-up at Member Database