Skip to main content

All friends

Overview​

All friends is the database of every LINE user on the OA — search and filter the list, view and edit an individual record, trigger a follower sync from LINE, export the whole friend base as CSV, and import LINE User IDs from a CSV file.

One thing to understand up front: this page isn't backed by a single module. The list table, the export and the import history come from the backend's report module; the detail modal, profile editing, the sync and the CSV import come from the line-user module — and the two are enforced at genuinely different levels (see Access control).

Who uses it — teams who need an overview of the follower base, want to check individual records before running a campaign, or need to correct customer data to match an upstream system.

User journey​

1. Search and filter the list​

  1. Open the All friends menu. The list always starts on the On system tab (the active tab is kept in the URL as ?tabs=system / ?tabs=history, so links are shareable and the browser back button works).
  2. Fill in the toolbar — Search (name or UID), Type (guest/member), Status (active/inactive) and Last active (7 days / 30 days / 90 days / 6 months / 1 year).
  3. Press Search — everything you type or pick is staged locally first and no request fires until you press Search (no request per keystroke). While any filter is applied, a Clear (n) button shows how many are active.
  4. Custom attribute columns are appended to the table automatically — but only attributes flagged as reportable in Settings > User Attribute Setup (pulled from a dedicated GET /attribute-master/reportable endpoint, not the full attribute list).

2. View and edit an individual record​

  1. Click the eye button at the end of a row to open the detail modal.
  2. The modal has two independently editable sections, each with its own edit/save buttons:
    • Profile information — first name, last name, email, mobile number (saved via PUT /line-users/{id}/profile)
    • Custom attributes — the input type follows what was declared in Attribute Setup (text/number/date/switch), saved via PUT /line-users/{id}/custom-attribute
  3. The LINE User ID and the display name are deliberately not editable — the UID is the identity link to LINE, and the display name is owned by the LINE profile sync.
  4. The modal also shows which target groups this friend belongs to, resolving audienceIds against the list from Segment audience to turn them into readable group names.
  5. On save, the main table refreshes immediately.

3. Review the website journey (GA journey)​

  1. Open the full detail page (/report/all-friends/detail?id=...) — most links into this page come from Segment audience.
  2. Pick a range of the last 7 / 14 / 30 / 90 days. The system shows an event timeline grouped by day, with an icon per event type (page_view, session_start, scroll, click, purchase).
  3. If this OA has no BigQuery sync configured, the page says so directly ("BigQuery sync is not configured for this LINE OA") rather than treating it as an error.

4. Sync followers and export data​

  1. Press Sync on the right of the toolbar to pull the latest follower list from LINE — the Last synced: {date} label next to the button shows the most recent successful run.
  2. Press Export to download the friend base as a CSV file (named report-all-friend DD-MM-YYYY HH-mm-ss.csv).

5. Import a list from a CSV file​

  1. Switch to the Import history tab and press Import file.
  2. Enter a job title and description, then drag in a CSV file containing a Line UserID column.
  3. The file is validated entirely in the browser before anything is sent — any bad rows open a table listing them one by one so they can be fixed first (blank rows, duplicate UIDs, malformed UIDs).
  4. Confirm the import. The system returns to the history tab so the status can be tracked (New / Processing / Failed / Completed), and the result file can only be downloaded once the status is "Completed".

Key details verified against the source​

Confirmed from internal/modules/report/{controller,service}.go and internal/modules/lineuser/{controller,service,repository,lineoa,gajourney}.go in line-management-cms-api-go:

  • The export button doesn't export "what's filtered" — it exports every friend on the OA. The frontend calls GET /report/all-friend/export with no query parameters at all, so limit falls back to its default of 0, which the backend reads as "apply no LIMIT". The result is every row of the OA regardless of what filters are sitting on screen.
  • The exported CSV travels as a single string inside a JSON body — the endpoint returns an object {fileName, csvData} (not a file attachment), and the frontend assembles the download itself. The bigger the friend base, the bigger that JSON blob, and it is all loaded into browser memory at once.
  • The export's columns are designed so nothing stored gets dropped — it starts with Line UserID, Display Name, Follow, Status, Created Date, then one column per attribute declared in an active attribute master (walking child attributes too), and finishes with any keys actually found in friends' custom_attribute that no definition covers (alphabetically sorted) — so values written from another source survive the export even after their definition is deleted.
  • The "Status" filter and the "Status" column in the table are two different pieces of data. The filter sends status, which the backend applies to line_user.status (the record's status), while the badge the table renders is lastActivityStatus (derived from recent activity). Filtering for "Active" and then seeing rows badged "Inactive" is exactly what the code does — not corrupted data.
  • The "Last active" filter works on last_activity_date and needs both ends of the range. The frontend converts the 7/30/90/180/365-day choice into a startDate+endDate pair automatically; the backend applies a BETWEEN expanded to start-of-day / end-of-day. Send only one side and the whole condition is skipped.
  • Table ordering is pinned to created_date DESC, always. The orderBy/order values the frontend holds in state are never actually sent with the request, and the backend offers no alternative ordering at all.
  • Search runs three conditions — display_name ILIKE, user_id ILIKE, and the concatenated first+last name (CONCAT(firstname, ' ', lastname)), where the third is assembled by interpolating the search text straight into the SQL (kept verbatim from the original for behavioral parity). Characters with special meaning in SQL/LIKE — %, _, ' — therefore behave unintuitively when searching by first/last name.
  • Syncing followers is a request that waits about 30 seconds. The backend checks the lock first (Redis plus a DB status column) and answers 409 immediately if a run is in flight; if it passes, it probes LINE's followers API with limit=1 as a capability test — and any failure at that step is interpreted as a 403 "requires a verified or premium LINE Official Account". Once the job is queued, the handler polls Redis every 3 seconds for up to 10 attempts and then answers 408 saying the sync has started but hasn't finished. The frontend renders 403/408/409 as a warning toast carrying the server's message, not an error.
  • The "last synced" status on screen comes from the database, not from the live lock. The endpoint the frontend asks for status reads a Redis hash under a LINE_USER:-prefixed key, while the sync flow writes its lock under LINE_OA: (kept as in the original). That read therefore almost never finds anything, and the answer always comes from line_oa.sync_follower_status.
  • The CSV import is validated twice, with rules that don't match. The frontend requires the header spelled exactly Line UserID (a case-sensitive comparison) and requires every UID to match ^U[a-f0-9]{32}$; the backend accepts the header case-insensitively (line userid or line_userid) and doesn't check UID format at this stage at all. The 10,000-row ceiling is checked on both sides.
  • The backend rejects non-comma delimiters by looking at the first line only — if the first line contains a tab, a semicolon (;) or a pipe (|), it answers 400 "Only comma (,) is allowed as a delimiter" straight away (checked in that order) — which means a column header that merely contains a semicolon in its name is enough to get the file rejected.
  • There is a one-import-at-a-time lock per LINE OA, with a 1-hour lifetime. A successful import sets a Redis key guarding against overlapping jobs; anyone starting another import during that window gets 409 with code LUS_004 ("another import process is currently running"). The lock expires on its own after an hour so it can't get stuck forever if the background job dies.
  • Import job titles must be unique, but the check ignores the organization — the duplicate check uses title + line_oa_id only, with no organization_id (as in the original). On a collision it answers 400 with code APP_CUSTOM plus a payload pointing at the title field, so the frontend can show the error inline on exactly that input.
  • The import row is created with total_records = 0 and status new — the real row count and the result file are the worker's job once it picks the message off the queue, so a freshly-queued import legitimately has no numbers to show yet.
  • File links in the import history are presigned URLs regenerated on every list load — both the source file and the result file. A copied link expires; it has to be clicked from a freshly-loaded list.
  • The import history's page/limit/search parameters go through no type validation at all — that endpoint's DTO is a plain interface rather than a validator-carrying class, so raw strings arrive and are only coerced numerically while computing the offset (unlike the friend-list endpoint, which does validate types).
  • Saving custom attributes is a merge, not a replace — the frontend sends the whole existing object back and overwrites only the edited keys, so existing keys are never dropped. Object- and array-typed attributes are shown read-only (no input at all), and keys present in the actual data but not defined in Attribute Setup are listed at the end for visibility but can't be edited.
  • The custom-attribute save endpoint answers 200 with an empty body — the original calls its response-formatting helper with no arguments, so the result is undefined and goes out as an empty body (kept as-is). The frontend therefore has to refetch after saving; it can't read the updated value out of the response.
  • Profile editing has a full set of backend rules — first/last name capped at 100 characters, email capped at 254 and format-checked, mobile number must be 9–10 digits after stripping spaces and dashes and is always stored digits-only so lookups and exports stay uniform. Sending an empty string means "clear this" (writes NULL), and sending no fields at all returns 400 "Nothing to update".
  • The GA journey is cached for 5 minutes under a key that doesn't include the day range. The cache key is just GA_JOURNEY:{id}, so switching the range selector from 30 days to 90 days within five minutes still returns the previous range's result — the cache has to expire before the new range shows up.
  • The GA journey deliberately includes pre-identification behavior. The BigQuery statement first finds every user_pseudo_id ever linked to this LINE UID, then pulls every event belonging to those pseudo ids, capped at 1,000 events in timestamp order. The identityMatchDate is the timestamp of the first event that carried a user_id — the dividing line between "we didn't know who this was yet" and "we do."
  • An incomplete BigQuery setup doesn't break the page — no enabled config, unreadable credentials, or a failed query all return an empty structure (totalEvents: 0, no sessions) plus a message explaining why, rather than throwing an error.
  • Segment audience — uses this friend base as the raw material for building target groups, and is the main source of links into the full detail page (the one with the GA timeline). In the other direction, the All friends detail modal calls that menu's list to turn audienceIds into readable group names.
  • Settings > User Attribute Setup — where every custom attribute is declared, and it drives two things at once: (1) which extra columns appear in the All friends table (only attributes flagged as reportable) and (2) which fields are editable in the detail modal (object/array types stay read-only).
  • Import Data — a genuinely different mechanism from the CSV import on this page. The All friends import sends a whole file of LINE User IDs onto a queue for a worker to create or reference friend records, requiring a single Line UserID header column and doing no field mapping whatsoever. Import Data instead updates values onto fields of friends who already exist, via a match key you choose and column mappings you configure yourself.
  • BigQuery Sync (per-OA setup) — an enabled config has to exist before the GA journey timeline on the detail page has any data.
  • Dashboard — its follower headline numbers read from this same dataset.

Things to watch out for​

  • The export button exports the whole OA, not the filtered result. If you need a specific segment, build a group in Segment audience and export from there instead.
  • A successful sync click means "job queued," not "data fully updated" — the request only waits about 30 seconds, and beyond that you get a warning saying it is still running. Wait and refresh; don't press again (pressing again during that window returns 409).
  • If the OA isn't a verified/premium account, the sync never works and always returns 403 — that is a LINE-side restriction, not a CMS permission setting.
  • After a successful import, the next one can only start once the previous job finishes (or after the 1-hour lock expires) — a job stuck in "Processing" unusually long is a worker-side thing to check.
  • The header in an import file must be spelled Line UserID with matching case, because the frontend check is stricter than the backend's — a file the backend would accept can be rejected in the browser before it is ever sent.
  • The result-file download button in the import history only becomes clickable once the status is "Completed," and the link is an expiring presigned URL — it can't be saved for later.
  • If you change the GA timeline's day range and nothing moves, wait five minutes for the cache to expire (the cache isn't keyed by range).
  • The "Status" badge in the table and the "Status" filter are not the same data (see the details section above) — don't use that filter to confirm how many people are badged "Active".

Access control (verified against the source)​

  • Frontend — the list page (/report/all-friends) and the full detail page (/report/all-friends/detail) are wrapped in a PermissionGuard checking the VIEW action on the report/all-friends subject.
  • The CSV import form page (/report/all-friends/form) carries no PermissionGuard at all — unlike the other two pages in the same group. Anyone already logged in can therefore open it straight from the URL, even without permission to view the friend list.
  • Backend — the report module is genuinely gated. All three routes (GET /report/all-friend, GET /report/all-friend/export, GET /report/import-history) are wrapped in ModuleGate(d, "report-all-friends") at the route-group level. If a platform admin closes this module for an organization, customer-type users in that org get a 403 immediately (onemoby-type callers always bypass the gate).
  • Backend — the line-user module is deliberately ungated. The routes behind the detail modal, profile editing, attribute editing, sync and CSV import (/line-users/*) live in the line-user module, which is explicitly registered in modulegate's exemptModules as "must never be gated", on the grounds that LINE user records underpin audiences, chat and reporting. The practical consequence: closing report-all-friends makes the list table 403, but the detail, profile-edit, sync and CSV-import endpoints remain callable. Those are governed by role/permission only.
  • ModuleGate is designed to fail open — if the caller can't be identified, no organization is in scope, or the config lookup fails, the request is allowed through (and logged), so a transient problem never locks out a whole module.
  • Action-level policy annotations (readAll / read / create / update / export) are declared on every route but run through the same no-op middleware as the rest of the system — the real enforcement is the module-level ModuleGate, not the action-level policy.
  • There is a governance test (internal/modules/modulegate/coverage_test.go) that scans the whole repo's source to confirm which modules are actually gated and which are on the exempt list — adding or removing a ModuleGate without updating that list fails the build.

Source file reference​

Frontend (cms-web-2026)

  • src/app/report/all-friends/page.tsx, detail/page.tsx, form/page.tsx — entry points for the list, full detail and import form (note the third has no PermissionGuard)
  • src/components/report/report-all-friends-table.container.tsx — tabs, filters, dynamic attribute columns, sync/export buttons
  • src/components/report/report-all-friends-table.tsx — the toolbar and every filter option
  • src/components/report/all-friends-detail-modal.tsx — the two independent edit modes, audienceIds resolution, non-editable attributes
  • src/components/report/user-ga-journey.tsx — the timeline and day-range selector
  • src/components/report/csv-form.container.tsx — all in-browser CSV validation and the handling of backend error codes
  • src/services/report.service.ts — the endpoints the frontend calls, and the assembly of the CSV file from csvData
  • src/utils/line.util.ts — the LINE User ID format rule used for frontend validation

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

  • internal/modules/report/controller.go — 3 routes plus ModuleGate("report-all-friends")
  • internal/modules/report/service.go — CSV assembly, attribute columns, import history and presigned URLs
  • internal/modules/lineuser/controller.go — the 8 /line-users routes (no ModuleGate)
  • internal/modules/lineuser/repository.go — search/filter conditions, ordering, and the "limit = 0 means no LIMIT" rule
  • internal/modules/lineuser/service.go — profile validation rules, the CSV import flow, the overlapping-import lock
  • internal/modules/lineuser/lineoa.go — the follower sync flow, the premium-OA probe, the polling wait
  • internal/modules/lineuser/gajourney.go — the BigQuery statement, the 5-minute cache, identity matching
  • internal/modules/modulegate/modulegate.go, coverage_test.go — the module gate mechanism and the exempt-module list

See the documentation team's earlier technical write-up at All Friends Report