Skip to main content

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​

  1. Click Upload CSV to open the form.

  2. Enter a name and description — both required (the backend runs IsNotEmpty on each). The name must not duplicate another group in the same LINE OA.

  3. 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 line Line UserID).

  4. Drop the file into the upload area — only files whose MIME type contains text/csv are 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.)

  5. 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.

  6. On confirm the frontend posts multipart/form-data to POST /audiences (create) or PUT /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_ids off 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 the audience_ids @> to_jsonb(?::int) containment operator, and (2) one CSV file per group on object storage, whose path is kept in info.lineUserIdsPathFilename. When the detail page loads, the backend mints a 24-hour presigned URL and injects it as info.presignedUrl for 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 U and 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 as totalFail.
  • 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_user table 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 are totalOriginal (rows in the file), totalSuccess (kept), and totalFail (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/csv on 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 /audiences treats file as optional; with no file, the backend inserts a row already in completed status 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 reads formValue.csvDetails.file.originFileObj with 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 MaxFileSize at 200,000,000 bytes (~200 MB), rejecting anything larger with File 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} overwrites info with the new file, resets the status to processing, and publishes an update job. The worker then snapshots the current members → strips this group's audience_ids off 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, both PUT and DELETE are rejected with AUD_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.apiClientId with no null guard, so the Go port preserves it by returning the same 500 with Cannot read properties of null (reading 'apiClientId') (deliberate bug parity).
  • GET /audiences/{id}/export has never actually worked, going back to the original — for a filter-type group it returns AUD_004, and for a CSV-type group the code dereferences a csvDetails.file field 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}/member flattens every failure into one 400 — the service wraps its whole body in a try/catch that always rethrows APP_000. Whether the group doesn't exist or the query failed, the caller sees the same 400 APP_000 and 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 the audience table), so an over-quota request is rejected before any file or queue is touched.
  • Filter audience — the sibling mode that builds a group from conditions instead of a file. It uses the same audience table, separated only by the dataSource column (csv vs filter). 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 totalSuccess is also the figure a campaign uses to compute its "Sent" count.
  • Rich menu — pick this group to restrict who sees which menu (the audienceId column 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 / totalOriginal to see how many were lost.
  • CSV-based groups support neither auto refresh nor manual recomputation — both PATCH /audiences/:id/auto-refresh and POST /audiences/:id/refresh check that dataSource is filter, 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 checks ability.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 — RegisterRoutes applies modulegate.ModuleGate(d, "audiences") to the entire /audiences group, 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/export on the Audiences subject) are declared on every route but run through the same no-op auth.CheckPolicies as 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 by line_oa_id + organization_id from the CLS context.
  • Deletion is a soft delete (stamping deleted_date) and every query carries deleted_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 points
  • src/components/audience/csv/csv-form.container.tsx — the file validators (checkCorrectUID, csvCheckEmptyAndDuplicate) and FormData assembly
  • src/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.tsx
  • src/components/audience/csv-detail-table/filter-detail-table.container.tsx — the member page and both export buttons
  • src/services/audience.service.ts — every endpoint the frontend calls
  • messages/{th,en}/audience.json — all copy (namespace audience)

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

  • internal/modules/audience/controller.go — all 11 routes, ModuleGate("audiences"), cron registration
  • internal/modules/audience/service.go — create/update/delete logic, file upload, presigned URLs, export-detail
  • internal/modules/audience/dto.go — multipart validation rules and the 200 MB file cap
  • internal/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 service
  • internal/audience/service.go — the user-ID regex, dropping unknown IDs, writing the CSV and stats
  • internal/csvaudience/csvaudience.go — the object-storage CSV reader that replaced DuckDB

See the technical reference at Audience Management