Segment audience — Multi-source filter
Overview
Multi-source filter creates a target group from multiple conditions combined. The data model is: one audience has multiple groups, and each group has multiple sources, with AND/OR joins at two levels — between sources inside a group, and between the groups themselves. Well suited to segmenting customers with conditions more elaborate than a single Filter audience condition allows.
The thing to understand before using it: the set combination happens in Go memory, not in SQL. The system resolves each source separately into its own array of LINE user ids, then intersects or unions those arrays in code. Several important behaviors — including one trap you need to know about — follow directly from that (see Key details).
Who uses it — marketing teams that need fine-grained segmentation, e.g. "clicked Campaign A AND (is a Gold-tier member OR has over 500 points remaining)."
User journey
1. Open the form and name it
Open Segment audience > Multi-source filter, or click Create segment from the list in Filter audience, which lands here by default. Enter an audience name (must be unique, and cannot be only whitespace).
The form accepts a ?preset= parameter carrying JSON with a name and a set of sources to start from — arriving that way shows a green banner reading "Using a saved preset: {name}", and the preset is applied exactly once so it never overwrites edits you make afterwards.
2. Add and configure sources
Click Add Source within a group to pick a type — the UI offers 7 types: campaign, Rich Menu, activity tag, Auto Response, form, custom attribute, and Loyalty. Each source renders as a card with a type-colored tag, a collapse toggle, a delete button, and an enable/disable switch (a disabled card dims and is excluded from the computation entirely). The group header shows a "{enabled}/{total} sources" tag.
Configuration by type:
- Campaign — pick a campaign from Campaign, check "Reach (View)" and/or any click-trackable spots, with its own AND/OR join.
- Rich Menu — pick the menu type (guest/member/custom/switch) from Rich menu, then the menu, then check its spots. If the menu has no click-recording actions, it reads "No trackable spots available. Only Link, Text and Switch rich menu actions record clicks."
- Activity tag — check the same 4 activity windows as single-source mode, with an AND/OR join.
- Auto Response — pick up to 5 rules per source from Auto-response message.
- Form — pick a form from Form builder, then build conditions from its questions (one group, but any number of conditions): question, operator, and value, with the value input adapting to the question type (date/number/option/text).
- Custom attribute — pick an attribute from two sections ("User Profile" and "Custom Attributes"), then an operator from the 13 available, filtered by that attribute's data type.
- Loyalty — one metric per source (points balance, earned, redeemed, tier, cards completed, days since last earn) with operators
>=/<=/=/ between, plus optional branch and date-range narrowing.
3. Set the joins and read the diagram
- Between sources within a group — small AND/OR radio buttons in the group card header (defaults to AND).
- Between groups — the "AND (Intersection)" / "OR (Union)" buttons on the top card (defaults to OR). A group is only removable when there is more than one.
The right-hand panel draws a live Venn diagram as you edit, with no preview needed. A group of 1–2 sources draws as the classic two circles, 3 as a triangular cluster, and 4 or more arranges into a 2-row grid (it does not cap at 3 circles the way conventional Venn diagrams do). AND draws the true N-way overlap across every circle and labels the center "AND"; OR shades the full lobes. Groups stack downward separated by an ∩ or ∪ pill, ending in a "= Audience" chip.
4. Preview the results and create
- Click Preview Results — this posts to
POST /audiences-filter/preview-multi-source-filter, and the system returns the full total count, the first 10 matching users, and how many matched each source (the "Source breakdown" strip). - The preview table shows No., User (avatar + name), User ID (copyable), a Sources column listing which sources that person matched, and user type. If any enabled form-type source exists, an extra Form data column appears to open that user's latest answers.
- Click create — the create button only enables once a preview has run successfully, and on click it also checks two more things: a non-whitespace name, and at least one enabled source.
- The system posts to
POST /audiences-filter/create-multi-source-filterand returns to the list in Filter audience, which is shared with single-source groups.
Key details verified against the source
Confirmed from internal/modules/audiencefilter/{controller,service,dto,multi_source_query_builder}.go in line-management-cms-api-go:
- The set combination happens in memory, not in SQL —
buildGroupQueryresolves each enabled source separately into an array of user ids, thencombineUserIdSetsmerges them: AND intersects starting from the smallest set (stable-sorted by length, then narrowed down from there) to reduce work, preserving that smallest set's order; OR unions preserving first-seen order.buildCombinedQuerythen merges each group's result with the top-level join using the very same function. - ⚠️ Empty sets are dropped before combining, so AND doesn't produce the empty result you'd expect — this is the most important trap on this page.
combineUserIdSetsalways filters empty sets out first (nonEmpty), andbuildCombinedQuerylikewise filters out groups that counted zero before the top-level merge (nonEmptyGroupResults). The consequence: "clicked Campaign A AND Gold tier", in a case where nobody is Gold, returns everyone who clicked Campaign A — not zero people. A condition that matches nobody gets ignored rather than collapsing the result to the empty set. Always read the "Source breakdown" strip in the preview: any source showing0had no effect on the result at all. - The backend supports 8 source types; the UI offers only 7 — the backend enum covers
campaign,rich_menu,activity_tag,auto_response,form/form_builder(two names for the same handler),custom_attribute,loyalty, andtrigger_rule, which has a fully working resolver ingetTriggerRuleUserIdsbut no UI to select it — reachable only by calling the API directly. - Form-type sources are narrowed to people who are still friends, unlike single-source mode —
getFormBuilderUserIdscallskeepFollowersto drop anyone whosefollow != 'yes', with a comment stating the reasoning: "A form submission proves somebody filled a form once, not that they can still be reached today." It also explains why the filter is applied afterwards rather than inside the query:buildQueryis shared with the standalone form-filter feature, and adding a follow predicate there would silently change that too. The upshot is that the same form conditions yield more people in single-source Filter audience and fewer as a source here. - Rich Menu sources accept no AND/OR join at all —
richMenuSourceConfighas noconditionfield, andgetRichMenuUserIdshardcodes OR with the comment "Rich menu typically uses OR." So checking several spots on one menu always means "tapped any of them"; there is no way to express "tapped all of them." (Campaign, activity tag, auto response, and trigger rule sources do pass their join through.) - Custom attribute conditions carry a SQL-injection guard at the field-name level — an attribute prefixed
__fixed.maps to a real column name from a 10-entry whitelist (display_name,firstname,lastname,email,mobile_no,language,follow,user_type,registered_date,last_activity_date), and anything outside it is rejected. Other attributes becomecustom_attribute->>'key', where the key must first pass the regex^[a-zA-Z0-9_.-]+$. - This is the only route that will create a group with no members — if
CreateMultiSourceFiltercomputes zero users, it still inserts the row and closes it ascompletedwith all-zero stats and an emptylineUserIdsPathFilename(no CSV file).create-filterandcreate-form-filterboth reject with a 400 instead — so an empty group sitting at "Completed" in the list can only have come from here. - Creation is synchronous like single-source mode, with no queue — compute → insert the row → write
audience_idsin batches of 5,000 → upload the CSV to object storage → close the status ascompleted, all within the one request. filterInfostores both the conditions and a snapshot of that computation's results — beyondisMultiSource,sourceJoin, andgroups, it also recordsgroupResults(each group's count) andsourceResults(each source's count) on the group. That's a point-in-time snapshot, not updated by later recomputations — and this same structure is what the auto-refresh job reads back to re-evaluate the conditions, through theisMultiSourcebranch ofrefreshAudience.- The preview never returns more than 10 rows, but the total is a real figure — the DTO validates
limitwith@Min(1) @Max(10), while the total comes from the full computed list. The Source breakdown numbers are the full per-source counts too, not truncated by the limit. - The Sources column in the preview table is computed by scanning every source's result — the system walks all source results and records which ones each user appeared in, preserving first-seen order (mirroring JavaScript Set ordering), so you can see which condition brought each person in.
- Disabled sources are excluded at two levels —
getSourceUserIdsreturns an empty array immediately whenenabled = false, andbuildGroupQueryalso filters disabled sources out before computing. A group with no enabled sources returns zero users and is then dropped from the top-level merge (per the empty-set rule above). - The Venn diagram has two gaps — (1) the
loyaltytype is absent from the visualizer's color table, so it draws grey and labels itself with the raw enum stringloyalty; and (2) the component accepts a total-count prop but the form never passes it, so the "= Audience" chip never displays a number of people. - The Loyalty source's copy hasn't been translated into English — the entire
sourceEntryCard.loyaltyCfg.*key set inmessages/en/audience.jsonis still Thai text, so users on English see the Loyalty card in Thai.
Related menus in this journey
- Filter audience — shares this page's list and detail views entirely; only the creation step differs, and it's where the system returns after a successful create. Groups from both modes are stored with the same
dataSource = 'filter', so both join the auto-refresh cycle identically (the cycle's details are documented on that page). - Upload audience — the file-based mode; it supports neither this kind of set combination nor auto refresh.
- Campaign — the data source for campaign sources, supplying all their tracking spots to tick.
- Rich menu — the data source for Rich Menu sources (and the one type where the AND/OR join has no effect).
- Auto-response message — the data source for Auto Response sources, up to 5 rules per source.
- Form builder — the data source for form sources: the form list, the question list, and the latest answers shown in the preview table's Form data dialog.
Things to watch out for
- Always run "Preview Results" successfully first — the create button stays disabled if you skip it, and previewing runs no client-side completeness check before firing; a backend error surfaces only as the generic "Failed to preview filter results" with the state reset.
- Watch the "empty sets are ignored" rule above all else — an AND condition that nobody matches won't zero out the result, it just drops out of the equation, giving you a broader group than intended. Check the Source breakdown strip every time for any source showing
0. - The result is a snapshot of the moment you clicked create — unless you enable auto refresh from the list page, the group never recomputes itself.
- Form conditions don't give the same answer on both pages — here anyone who unfollowed is dropped; in single-source mode they aren't. That's the explanation when the numbers disagree.
- English users see the Loyalty card in Thai, and the diagram draws a Loyalty source as a grey circle labeled
loyalty. - The Copy button in the list always lands here, but only hydrates from multi-source groups — copying a single-source group gives you an empty form.
- The two levels have different join defaults — AND between sources within a group, but OR between groups. Add a second group without touching the join and you get a union, not an intersection.
Access control (verified against the source)
Almost everything on this page runs through the audiencefilter backend module, which is distinctly less enforced than the audience module — verified file by file.
- The
audiencefiltermodule (routes/api/audiences-filter/*) contains not a singlemodulegate.ModuleGateline —controller.goregisters all 11 authenticated routes directly on theauthedgroup with nothing but the no-opauth.CheckPolicies(auth.Can(...)). SoPOST /audiences-filter/create-multi-source-filterandPOST /audiences-filter/preview-multi-source-filter, the two routes this page depends on, remain callable even after a platform admin has closed the audiences module for that organization. - The
audiencemodule (routes/api/audiences/*) is the opposite —modulegate.ModuleGate(d, "audiences")wraps the whole group. The resulting consequence: with the module closed, a user can create a new group from this page but cannot see the groups they created (GET /audiencesreturns 403), and cannot toggle auto refresh or trigger a recomputation. That inconsistency comes from a gate missing in one module, not from design. - The only route in this module with real enforcement is
POST /audiences-filter/internal/refresh— on the public group, no JWT, wrapped in a guard comparing thex-internal-keyheader to theINTERNAL_API_KEYenv var. With an empty env var it rejects everything with 401Invalid internal API key. GET /audiences-filter/form-response(the preview table's Form data dialog) is thinner than the rest — it only checks thatlineUserIdandformIdswere supplied, never that the caller is entitled to read that particular person's answers. Its safety rests solely on theline_oa_idscoping from CLS in the form lookup, and it returns a bare 500 on query failure (no AppError wrapping).- No audience page on the frontend has a
PermissionGuard, this one included. The only control is hiding the side-menu entry viaability.can('view', 'audience')(this page's submenu key isaudience-multi-source); typing the URL directly isn't blocked. - Every query is still scoped by
line_oa_idfrom the CLS context — the per-source user lookups, the duplicate-name check, and the form-answer fetch alike — so nothing leaks across OAs. What's missing is the module-level gate, not the tenant boundary.
Source file reference
Frontend (cms-web-2026)
src/app/audience/filter/multi-source/form/page.tsx— form entry point (accepts?id=,?action=copy,?preset=)src/components/audience/filter/multi-source-filter-form.container.tsx— thehasPreviewedgate, pre-submit checks, preset hydration and copy modesrc/components/audience/filter/multi-source-filter-form.tsx— the 7 source types, both join levels, the Preview Results buttonsrc/components/audience/filter/components/source-entry-card.tsx— every source card and all their config inputssrc/components/audience/filter/components/multi-source-visualizer.tsx— the Venn diagram (geometry by source count, per-type color table)src/components/audience/filter/multi-source-filter-table.tsx— the preview table, Source breakdown strip, Form data dialogsrc/components/audience/interfaces/multi-source-filter.interface.ts— theMultiSourceFilterFormValues/SourceGroup/SourceFilterEntrymodelsrc/services/audience-filter.service.ts
Backend (line-management-cms-api-go)
internal/modules/audiencefilter/controller.go— the multi-source create/preview routes, no ModuleGate,formResponsePreview,internalApiKeyGuardinternal/modules/audiencefilter/multi_source_query_builder.go—getSourceUserIds(8 types),combineUserIdSets(the AND/OR rules and the empty-set drop),keepFollowers,getCustomAttributeUserIds,getLoyaltyUserIdsinternal/modules/audiencefilter/service.go—CreateMultiSourceFilter,PreviewMultiSourceFilter,GetFormResponsePreview, and readingfilterInfoback on refreshinternal/modules/audiencefilter/dto.go— the nestedgroups/sourcesvalidation rules and thelimitcap of 10internal/modules/audiencefilter/enums.go— all 9 source-type enum values (including the UI-lesstrigger_rule)internal/modules/audiencefilter/form_filter_query_builder.go— the form-condition query builder shared with single-source mode
See the technical reference at Audience Management