Skip to main content

Friend-add Tracking Campaigns

Overview

This is how the platform measures where an OA's new friends come from. An admin creates a campaign with a token and places the resulting link across channels — ads, an in-store QR code, an article. When a user taps it, the LIFF page records a "visit" before sending them on to add the OA as a friend, and when LINE later delivers the follow webhook, the system can attribute that follow back to the original visit.

The feature exposes three endpoints: finding the active campaign for an OA hash, fetching campaign details by token, and recording a visit.

Business Flow

Find a campaign by OA hash — GET /api/friend-track/by-hash/:hash

  1. Look up line_oa by hash without any status or soft-delete filter (kept for parity with the legacy system). A miss returns 404 "LINE OA not found".
  2. Find the OA's most recent active, non-deleted campaign. A miss returns 404 "No active campaign found for this LINE OA".
  3. Return {token, name, description, botBasicId}. description is a pointer so that a NULL column serializes as JSON null rather than disappearing from the payload.

Fetch campaign details by token — GET /api/friend-track/:token

  1. Find the active campaign by token, including the line_oa relation. A miss returns 404 "Campaign not found".
  2. A campaign with no OA relation returns 404 "LINE OA not found for this campaign".
  3. Return {name, description, botBasicId, lineLiffId, lineOaHash}, where lineLiffId comes from line_login_info.lineLiffId and falls back to an empty string.

Record a visit — POST /api/friend-track/:token/visit (rate limit 10/60s)

Body {displayName?, pictureUrl?, refId?}. Every field is optional and bind errors are ignored, because the user's identity comes from the headers, not the body.

  1. Resolve the campaign and OA using the same 404 rules as above.
  2. Verify the token. With an x-liff-token header, the OA must have a configured channel (otherwise 401 "LINE Login not configured for this channel") and VerifyIDToken runs. Without it, the flow falls back to x-liff-access-token and VerifyAccessToken. With neither, the response is 401 "No authentication token provided". Note that this path calls the raw verify methods directly rather than going through liff.Service, because that service auto-provisions a guest user — not the behavior wanted here.
  3. Dedup rules that still allow a recount — the most subtle part of the feature.
    • Find the latest liff_visit event for the (campaign, user) pair.
    • If one exists, find the latest unfollow event for the (user, OA) pair and compare timestamps.
      • No unfollow after the visit means the same funnel, so the endpoint returns {success:true, isFriend, alreadyTracked:true} without writing anything.
      • An unfollow after the visit means a new funnel, so the flow continues — someone who left and came back deserves to be counted again.
  4. Insert a friend_track_event row of type liff_visit. displayName and pictureUrl prefer the body values and fall back to the token's; an empty refId is stored as null.
  5. Publish the trigger {campaignId, eventType, lineUserId, lineOaId, organizationId} to the friend_track_event_trigger queue on a best-effort basis, logging and swallowing any error.
  6. Merge custom attributes when the campaign has an attribute_config object with at least one key, or has a ref_attribute_key and a refId was supplied.
    • The merged value is the existing custom attributes overlaid with the attribute config, plus refAttributeKey set to refId when present.
    • The update only runs when a line_user row already exists (guarded by findOne); it never creates one.
  7. Check whether the user is already a friend via IsFriend.
  8. If they already are, a follow event is recorded too (when one does not already exist), because LINE does not fire the FOLLOW webhook for someone who was already a friend. Without this step, the funnel would sit at "visit" forever.
  9. Return {success:true, isFriend, alreadyTracked:false}.

Key Files & Functions

RouteRate limitHandler
GET /api/friend-track/by-hash/:hashinternal/friendtrack/handler.go(*Handler).GetByHash
GET /api/friend-track/:token(*Handler).GetCampaign
POST /api/friend-track/:token/visit10/60s(*Handler).RecordVisit
  • internal/friendtrack/register.goRegister(r, deps), which attaches RouteRateLimit(rdb, 10, 60) to the visit route only
  • internal/friendtrack/service.goGetCampaignByLineOaHash, GetCampaignByToken, RecordVisit, mergeAttributes, publishTrigger, coalesce, nilIfEmpty, hasNonEmptyObject
  • internal/friendtrack/repository.goFindLineOaByHash, FindActiveCampaignByOaLatest, FindActiveCampaignByToken, FindLatestEvent, FindEventByType, InsertEvent, IsFriend, FindLineUserCustomAttribute, UpdateLineUserCustomAttribute
  • internal/friendtrack/entity.goCampaign, EventInput, EventTypeLiffVisit, EventTypeFollow, EventTypeUnfollow
  • Response types — ByHashResponse, ByTokenResponse, VisitResponse

Connections to Other Services

  • Tables friend_track_campaign (token, jsonb attribute_config, ref_attribute_key), friend_track_event (event_type of liff_visit/follow/unfollow), line_oa, and line_user (jsonb custom_attribute)
  • The RabbitMQ queue friend_track_event_trigger, consumed by the worker that evaluates trigger rules
  • Real follow and unfollow events come from line-management-webhook-go; this feature writes only liff_visit plus the supplementary follow for already-friends
  • content-page-viewer emits the friendTrack block (campaignToken and buttonText) that the web app uses to call these endpoints
  • liff-authentication — this feature uses the raw verifier rather than liff.Service
  • Corresponding client-web feature: friend-track