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
- Look up
line_oaby hash without any status or soft-delete filter (kept for parity with the legacy system). A miss returns 404"LINE OA not found". - Find the OA's most recent active, non-deleted campaign. A miss returns 404
"No active campaign found for this LINE OA". - Return
{token, name, description, botBasicId}.descriptionis a pointer so that a NULL column serializes as JSONnullrather than disappearing from the payload.
Fetch campaign details by token — GET /api/friend-track/:token
- Find the active campaign by token, including the
line_oarelation. A miss returns 404"Campaign not found". - A campaign with no OA relation returns 404
"LINE OA not found for this campaign". - Return
{name, description, botBasicId, lineLiffId, lineOaHash}, wherelineLiffIdcomes fromline_login_info.lineLiffIdand 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.
- Resolve the campaign and OA using the same 404 rules as above.
- Verify the token. With an
x-liff-tokenheader, the OA must have a configured channel (otherwise 401"LINE Login not configured for this channel") andVerifyIDTokenruns. Without it, the flow falls back tox-liff-access-tokenandVerifyAccessToken. With neither, the response is 401"No authentication token provided". Note that this path calls the raw verify methods directly rather than going throughliff.Service, because that service auto-provisions a guest user — not the behavior wanted here. - Dedup rules that still allow a recount — the most subtle part of the feature.
- Find the latest
liff_visitevent for the (campaign, user) pair. - If one exists, find the latest
unfollowevent 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.
- No unfollow after the visit means the same funnel, so the endpoint returns
- Find the latest
- Insert a
friend_track_eventrow of typeliff_visit.displayNameandpictureUrlprefer the body values and fall back to the token's; an emptyrefIdis stored as null. - Publish the trigger
{campaignId, eventType, lineUserId, lineOaId, organizationId}to thefriend_track_event_triggerqueue on a best-effort basis, logging and swallowing any error. - Merge custom attributes when the campaign has an
attribute_configobject with at least one key, or has aref_attribute_keyand arefIdwas supplied.- The merged value is the existing custom attributes overlaid with the attribute config, plus
refAttributeKeyset torefIdwhen present. - The update only runs when a
line_userrow already exists (guarded byfindOne); it never creates one.
- The merged value is the existing custom attributes overlaid with the attribute config, plus
- Check whether the user is already a friend via
IsFriend. - If they already are, a
followevent 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. - Return
{success:true, isFriend, alreadyTracked:false}.
Key Files & Functions
| Route | Rate limit | Handler |
|---|---|---|
GET /api/friend-track/by-hash/:hash | — | internal/friendtrack/handler.go → (*Handler).GetByHash |
GET /api/friend-track/:token | — | (*Handler).GetCampaign |
POST /api/friend-track/:token/visit | 10/60s | (*Handler).RecordVisit |
internal/friendtrack/register.go—Register(r, deps), which attachesRouteRateLimit(rdb, 10, 60)to the visit route onlyinternal/friendtrack/service.go—GetCampaignByLineOaHash,GetCampaignByToken,RecordVisit,mergeAttributes,publishTrigger,coalesce,nilIfEmpty,hasNonEmptyObjectinternal/friendtrack/repository.go—FindLineOaByHash,FindActiveCampaignByOaLatest,FindActiveCampaignByToken,FindLatestEvent,FindEventByType,InsertEvent,IsFriend,FindLineUserCustomAttribute,UpdateLineUserCustomAttributeinternal/friendtrack/entity.go—Campaign,EventInput,EventTypeLiffVisit,EventTypeFollow,EventTypeUnfollow- Response types —
ByHashResponse,ByTokenResponse,VisitResponse
Connections to Other Services
- Tables
friend_track_campaign(token, jsonbattribute_config,ref_attribute_key),friend_track_event(event_typeofliff_visit/follow/unfollow),line_oa, andline_user(jsonbcustom_attribute) - The RabbitMQ queue
friend_track_event_trigger, consumed by the worker that evaluates trigger rules - Real
followandunfollowevents come from line-management-webhook-go; this feature writes onlyliff_visitplus the supplementaryfollowfor already-friends - content-page-viewer emits the
friendTrackblock (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