Segment audience — Upload audience
Overview
Upload audience creates a target group (Audience) from a CSV file of LINE User IDs prepared by the user themselves. Great for teams that already have a customer list from another system (e.g. a CRM) and want to bring it in directly, with no filter conditions needed.
The one thing to understand up front: cms-api never reads the uploaded CSV at all — it stores the file on object storage and publishes a job onto RabbitMQ, and a worker does the parsing, ID validation, and membership wiring. A successful save therefore means "the file is queued," not "the list is ready" (see Key details).
Who uses it — marketing teams that already have a ready customer list, with permission to the audience module (this module carries a real, enforced ModuleGate on the backend — see Access control).
User journey
1. Review the uploaded groups
Open Segment audience > Upload audience. This list reuses the same container as the Filter audience page but calls GET /audiences with dataSource=csv pinned, so it only ever shows file-based groups (there are no tabs). The filter bar has 2 fields — search by group name, and a status select (Completed / Processing).
Columns — No., group name (click through to the member list), Api Client, description, member count (totalSuccess / totalOriginal), status, file name, created date, and the action buttons.
2. Upload a file to create a group
-
Click Upload CSV to open the form.
-
Enter a name and description — both required (the backend runs
IsNotEmptyon each). The name must not duplicate another group in the same LINE OA. -
Prepare the file — a collapsible "CSV file guideline" panel walks through 5 steps and offers a template download (
/template/audiences_template.csv, which contains nothing but the single header lineLine UserID). -
Drop the file into the upload area — only files whose MIME type contains
text/csvare accepted. The front end validates immediately before submitting, walking row by row for 3 kinds of problem:- blank row → "Could not find the UID"
- duplicate ID →
"Found the same UID as in row {row}" - malformed ID →
"Incorrect UID format in row {row}"
Any error opens a "The data is incorrect." dialog with a Row / Description table and the file is not kept at all — you fix the file and drag it in again. (Reported row numbers count the header line as row 1.)
-
Confirm — once validated, a confirmation dialog states
"Analysis find format for {count} rows."Editing an existing group adds the line "Do you want to replace the old information?", because edit mode replaces the entire member list rather than appending to it. -
On confirm the frontend posts
multipart/form-datatoPOST /audiences(create) orPUT /audiences/{id}(edit) and returns to the list. The group shows as Processing until the worker finishes, then flips to Completed with its real member count.
3. Review members and export
Click a group name to open its member list — the table shows UID, display name, first name, last name, and friend status (Friend / Blocked), filterable by search and status. There are two export buttons:
- Export — opens the presigned URL of the member CSV the system built on object storage (valid 24 hours).
- Export with attributes — calls
GET /audiences/{id}/export-detail, producing a CSV with the full profile columns plus a column for every custom attribute defined on that OA (headers taken from the attribute master labels, prefixed with a UTF-8 BOM so Excel reads Thai text correctly).
4. Manage an existing group
- Edit — reopens the same form; a new file must be attached to replace the old one.
- Delete — with confirmation. It's a soft delete followed by a delete job on the queue for the worker to strip
audience_idsoff each user. - Groups created by an external system (
apiClientId > 0) hide every action button, and their auto-refresh cell shows-.
Key details verified against the source
Confirmed from internal/modules/audience/{controller,service,dto,scheduler}.go in line-management-cms-api-go, and internal/audience/{consumer,service}.go + internal/csvaudience/csvaudience.go in line-management-worker-go:
- Group membership isn't rows in a join table — it lives in two places at once — (1) the jsonb column
line_user.audience_ids, an array of audience ids queried with theaudience_ids @> to_jsonb(?::int)containment operator, and (2) one CSV file per group on object storage, whose path is kept ininfo.lineUserIdsPathFilename. When the detail page loads, the backend mints a 24-hour presigned URL and injects it asinfo.presignedUrlfor the frontend to download — if the file doesn't exist yet (processing hasn't finished), it returns the raw entity with no presignedUrl at all. - The frontend and the worker validate LINE User IDs by different rules, and the worker is stricter — the frontend only checks "starts with
Uand is 33 characters long," never looking at the remaining characters. The worker uses the regex^U[a-f0-9]{32}$, which accepts lowercase hex only. So an ID typed in uppercase (e.g.U1A2B…) sails through the browser check and is then silently discarded by the worker, surfacing later astotalFail. - The worker keeps only IDs the system already knows about — the create-from-file step reads the CSV out of storage in chunks of 1,000 rows, and every chunk is matched against the
line_usertable for that OA/organization. IDs not already present are dropped (this path never calls LINE to fetch a profile and create a new user). That's the main reason 10,000 uploaded rows can yield far fewer actual members — the recorded stats aretotalOriginal(rows in the file),totalSuccess(kept), andtotalFail(the difference). - The original NestJS used DuckDB to read the file; the Go port doesn't — the source loaded the CSV into an in-memory DuckDB table to count and paginate it. The Go port streams the object straight from S3/MinIO and parses it with
encoding/csvon every call. The source comment states the reason outright: the old approach left a persistent temp table around, which was a cross-OA data-leak hazard. - Clicking create with no file doesn't error at the API level — it produces an empty group —
POST /audiencestreatsfileas optional; with no file, the backend inserts a row already incompletedstatus with all-zero stats and publishes nothing to the queue (this path exists so external systems can create an empty group). The frontend, however, breaks before it gets there: the confirm handler readsformValue.csvDetails.file.originFileObjwith no null check — so "save in edit mode without attaching a new file" is a JavaScript error in the browser, not a readable validation message. - The file-size cap exists only on the backend — the DTO enforces
MaxFileSizeat 200,000,000 bytes (~200 MB), rejecting anything larger withFile size 200000000 exceeded.The frontend has no size check whatsoever, only a MIME-type check — so a user can wait out a huge upload and only then get the error back. - Editing wipes and rebuilds the whole set rather than patching part of it —
PUT /audiences/{id}overwritesinfowith the new file, resets the status toprocessing, and publishes an update job. The worker then snapshots the current members → strips this group'saudience_idsoff every user → reruns the entire create routine → diffs who joined (added) and who fell out (removed) → and forwards that delta to the audience-trigger evaluation. Groups wired into an automation are therefore fired on the people who actually entered or left, not on the whole list. - External-system groups are locked behind a misleading error code — when
apiClientId > 0, bothPUTandDELETEare rejected withAUD_001, whose canonical message is "Duplicate title," even though the real reason is "this is a 3rd-party audience." Behavior carried over verbatim from the original. - Editing or deleting a nonexistent group returns 500, not 404 — the TypeScript original read
audienceInfo.apiClientIdwith no null guard, so the Go port preserves it by returning the same 500 withCannot read properties of null (reading 'apiClientId')(deliberate bug parity). GET /audiences/{id}/exporthas never actually worked, going back to the original — for a filter-type group it returnsAUD_004, and for a CSV-type group the code dereferences acsvDetails.filefield that doesn't exist on the entity, throwing a TypeError → 500 every time, without ever writing a byte of CSV. The export button that does work is/export-detail, which is an addition made in the Go port.GET /audiences/{id}/memberflattens every failure into one 400 — the service wraps its whole body in a try/catch that always rethrowsAPP_000. Whether the group doesn't exist or the query failed, the caller sees the same 400APP_000and cannot tell the cases apart from the API response.- Group count is capped by the subscription plan — every create path calls
AssertQuota(ResourceSegment)first (counting the organization's non-deleted rows in theaudiencetable), so an over-quota request is rejected before any file or queue is touched.
Related menus in this journey
- Filter audience — the sibling mode that builds a group from conditions instead of a file. It uses the same
audiencetable, separated only by thedataSourcecolumn (csvvsfilter). A file-uploaded group can never enable auto refresh (see Things to watch out for). - Multi-source filter — the form for building a group from several conditions; same family, but routed through a different backend module.
- Campaign — pick this group as the recipient when sending as Multicast; this group's
totalSuccessis also the figure a campaign uses to compute its "Sent" count. - Rich menu — pick this group to restrict who sees which menu (the
audienceIdcolumn on the menu). - Auto-response message and Form builder — not directly involved in upload mode, but they are the data sources behind Filter mode on the next page.
Things to watch out for
- A successful save ≠ a usable list — cms-api only stores the file and queues a job. Wait for the status to flip to "Completed" with a number in the member column before using the group to send anything.
- The member count will almost always be lower than the row count in the file — IDs the system has never seen and IDs typed in uppercase both get dropped. Read the member column as
totalSuccess / totalOriginalto see how many were lost. - CSV-based groups support neither auto refresh nor manual recomputation — both
PATCH /audiences/:id/auto-refreshandPOST /audiences/:id/refreshcheck thatdataSourceisfilter, otherwise returning 400 with "Only filter-based audiences can be auto-refreshed" / "…can be refreshed." For a self-updating group, use Filter audience or Multi-source filter. - Edit mode is replace-everything, and saving without a file crashes the page — not a readable error. Treat "edit" as always meaning "upload a fresh file."
- The validator reads the raw file text, so a file ending in a blank line (which many editors produce) gets reported as "Could not find the UID" on the final row.
Access control (verified against the source)
- Frontend — none of the audience pages are wrapped in a
PermissionGuard, unlike 20+ other pages in the system. The only gating is side-menu visibility, which checksability.can('view', 'audience')— typing the URL directly is not blocked, so server-side enforcement is what actually matters here. - Backend — this module carries a real, enforced ModuleGate —
RegisterRoutesappliesmodulegate.ModuleGate(d, "audiences")to the entire/audiencesgroup, so every route (list, get, create, update, delete, member, member-logs, export, export-detail, auto-refresh, refresh) is covered. If a platform admin closes the audiences module for an organization, customer-type users in that org get a 403 immediately even calling the API directly (onemoby-type callers bypass the gate). - But the sibling audiences-filter module has no ModuleGate at all — see the Filter audience and Multi-source filter pages. This is worth knowing, because the create-from-conditions routes live in that module.
- Action-level policy annotations (
readAll/read/create/update/delete/exporton theAudiencessubject) are declared on every route but run through the same no-opauth.CheckPoliciesas most other modules — the real enforcement is the module-level ModuleGate, not the action-level policy. - Every route sits behind the global JWT guard (in the NestJS original, the class-level
@Public()line is commented out, so the Go port registers everything on the authed group), and every query is scoped byline_oa_id+organization_idfrom the CLS context. - Deletion is a soft delete (stamping
deleted_date) and every query carriesdeleted_date IS NULL— this module has no hard-delete route.
Source file reference
Frontend (cms-web-2026)
src/app/audience/csv/page.tsx,src/app/audience/csv/form/page.tsx,src/app/audience/csv/detail/page.tsx— list, form, and member-page entry pointssrc/components/audience/csv/csv-form.container.tsx— the file validators (checkCorrectUID,csvCheckEmptyAndDuplicate) and FormData assemblysrc/components/audience/csv/csv-table.container.tsx,csv-table.tsx— table/filters/action buttons (shared with the Filter audience page)src/components/audience/csv/modal/—modal-confirm-upload.tsx,modal-error-upload.tsx,modal-csv-template.tsxsrc/components/audience/csv-detail-table/filter-detail-table.container.tsx— the member page and both export buttonssrc/services/audience.service.ts— every endpoint the frontend callsmessages/{th,en}/audience.json— all copy (namespaceaudience)
Backend (line-management-cms-api-go)
internal/modules/audience/controller.go— all 11 routes,ModuleGate("audiences"), cron registrationinternal/modules/audience/service.go— create/update/delete logic, file upload, presigned URLs,export-detailinternal/modules/audience/dto.go— multipart validation rules and the 200 MB file capinternal/modules/audience/scheduler.go— the auto-refresh cron (covered on the Filter audience page)
Worker (line-management-worker-go)
internal/audience/consumer.go— binds the create/update/delete/mookept queues to the serviceinternal/audience/service.go— the user-ID regex, dropping unknown IDs, writing the CSV and statsinternal/csvaudience/csvaudience.go— the object-storage CSV reader that replaced DuckDB
See the technical reference at Audience Management