Apps — Loyalty
Overview
Loyalty is one of the "Apps" — an optional add-on module gated by an AppEnabledGuard rather than by the per-module, role-based permission system every other menu uses. Concretely: a platform admin switches this app on or off per organization from their own console, and then every route in the app checks a single line_oa_app row asking whether this organization is allowed to use it. Not allowed means 403 for the whole module. An organization cannot enable it for itself (see Access control).
The app's job is letting a merchant design a "collector card" their customers use through LINE, in one of two modes — stamps (stamp), a card with a fixed number of slots where a QR scan is worth a stamp per purchase, or points (point), where points accrue against spend and a membership-tier ladder can be switched on. The CMS side has 8 tabs — Card, Design, Rewards, Tiers, Segments, Staff, Log, Customer.
Who uses it — the shop owner or an admin who configures the entire programme from this CMS. The menu only appears once the app has been enabled for the organization.
User journey
1. Configure the programme and the card's look
- Open Apps > Loyalty and go to the Card tab. Pick stamp or point mode — you can toggle freely; the system labels the state explicitly (
"Previewing {mode}. Still saved as {savedMode} — nothing changes until you press Save."). - Fill in the fields for that mode — stamp mode picks the slot count (the UI offers 5 / 10 / 15, though the backend accepts up to 20); point mode takes a baht-per-point rate (1–100,000) and can switch tiers on.
- Set the expiry policy (
from_first/from_last/none— point mode has nofrom_last), the cooldown between earns (unlimited/per_hours1–72 /per_day), a welcome bonus, and the terms text (max 2,000 characters). - Save. If you changed the mode on an existing programme, a confirmation dialog spells out 3 consequences first (a new version is published / balances in the old unit stop counting but are not deleted / the reward ladder switches to the new unit's prices). That dialog appears on Save, not when you flip the toggle — toggling to look around has no effect yet.
- Publish the programme (
active) or pause issuing (paused). - Go to the Design tab. Upload a cover image, logo and stamp icon (stamp mode only), and pick the card colour, text colour, scan-button colour, and collected-stamp colour. A full card preview sits in the right column. Saving the design does not mint a new version.
2. Set up rewards and tiers
- Go to the Rewards tab and add a reward with the units needed to claim it — in point mode that's a "price" (up to 100,000); in stamp mode it's "at slot N" (up to the card's slot count). Attach an image, and if tiers are on you can reserve a reward for a given tier and above.
- In point mode with tiers on, go to the Tiers tab, create a tier, give it a rank (1–20), and write rules on spend (
spend) / order count (orders) / points (points) / months as a member (member_months) with the comparatorsgte/lte/eq, joined as "all of these" or "any one of these". - Mark one tier as the default — a default tier needs no rules at all, because it's where a customer lands by not qualifying for anything else.
- Hit "Calculate now" to evaluate every member immediately. The result reads back as "Checked X customers — Y moved tier".
3. Invite staff and hand off audience segments
- Go to the Staff tab and add a branch first (the empty state says so plainly: "Add at least one branch — every stamp records where it was given.").
- Invite a staff member by entering a name (the admin names them; the LINE display name isn't used, because the admin-chosen name is what every stamp is attributed to later) and picking a branch. The system mints a token once and turns it into an invite link to copy right there — the link works once, expires in 48 hours, and can never be read again.
- The staff member opens the link to identify themselves (status goes
invited→pending), and the admin approves them toactivefrom this table. They can be revoked (revoked) or reinstated later. - Go to the Segments tab and pick a preset matching the programme's mode (9 of them, e.g. "Almost done with the card", "Enough points to redeem but hasn't", "High-tier members"). The system takes you to Segment audience > Multi-source filter with the conditions and the group name pre-filled, ready to adjust.
4. Look up customers and read the history
- Go to the Customer tab and search by phone, name or email — a single hit opens the detail view directly, showing the balance, tier, visit count, total spent, and transaction history. It's read-only: there is no manual point-adjustment endpoint in the CMS at all.
- Go to the Log tab for the programme's most recent activity (the UI asks for 200 rows) — staff-issued stamps, reward claims, expiries, welcome bonuses, and manual adjustments made by other systems.
Key details verified against the source
Confirmed from internal/modules/loyalty/{controller,service_program,service_catalog,scope}.go and internal/modules/apps/guard.go in line-management-cms-api-go:
- Only 2 fields count as "changing the deal", and they're what triggers a new version —
ProgramInput.IsStructuralaccepts onlymodeandcardSize, because both alter what a stamp is worth; applying them in place would silently rewrite the terms of a card somebody is already halfway through filling. Everything else — name, cover, colours, terms text, expiry policy, cooldown — reaches every customer immediately, which is the point. More importantly, whether to version is inferred from the shape of the edit, never from a flag the caller sends — so a client cannot ask for an in-place structural change. When it does version, the old row is set tosuperseded, the new row getsversion + 1, and cards already issued snapshot their ownprogram_versionso they keep the old terms. - A mode change doesn't carry forward the latest ladder — it goes and finds the ladder of the mode you're switching TO —
copyChildrenexplains why: a reward is priced in units, and a mode change changes what a unit is. Carrying "5" from a stamp card onto a points programme prices a free drink at five points, which is nothing. So on a mode change it looks up the newest prior version that used the destination mode and restores that ladder — mirroring what the ledger does with lots (set aside while the other mode is active, returned untouched on the way back). A merchant who tries points for a week and switches back finds their stamp prices where they left them. With no such version there's nothing sensible to copy, and they price rewards in the new currency from scratch. - Rewards that fall off a smaller card are DROPPED, not clamped — if you reduce the slot count, a reward set at slot 10 on a card that now has 8 is simply not copied to the new version: it could never be reached anyway, and silently moving it to 8 would collide with whatever already sits there and change a reward the merchant never asked to change. Dropping is visible in the CMS, which is the honest outcome.
- Tiers are deliberately NOT versioned with the programme, and the comment says so outright —
copyChildrenleaves the tier table alone, because tiers hang off the LINE OA rather than off a programme version. Copying them on a re-version would orphan every customer account'stier_id. (A tier is a statement about a customer's standing across months of spending; a programme version is a change to the card's terms — different things.) For the same reason, deleting a tier archives it (status = 'archived') and moves its holders to the remaining default tier — or to no tier — at the moment of deletion, logging every move intotier_historywith reasondemote. Without that move, the archived tier is filtered out of the ladder and the recalculation would never clear the dangling reference, because an empty ladder short-circuits by design. "Why is my Silver gone?" is exactly the question this mechanism answers. - The backend overwrites mode-inconsistent values itself rather than trusting the frontend — in point mode
applyInputforcesCardSize = nil, and whenever mode isn'tpointit forcesTierEnabled = false. The comments give the real cause: antd preserves the values of unmounted fields, so the Card tab keeps posting the old slot count after a switch to points, and a stray size would silently turn a points balance back into stamp cards. Likewise, tiers left switched on across a switch to stamps would leave the nightly job evaluating every customer at zero. - Tier configuration travels the in-place path, and there was a bug where the save succeeded but the value never landed — the comment in
updateMaprecords it: omitting the tier columns from the in-place UPDATE made the save "silently succeed" — the response was built from the struct and reportedtierEnabled = truewhile the UPDATE never carried the column, so the database stayed false. - Pausing touches nobody's balance, and that's why the feature exists —
SetStatus('paused')stops issuance and nothing else: balances, cards and unclaimed rewards all survive and stay redeemable. The code comment draws the comparison directly — LINE's equivalent deletes every accumulated point, which is the behaviour this feature exists to avoid. - The staff invite token is designed to be unreadable-twice right down at the struct level —
randomToken(32)draws fromcrypto/rand(notmath/rand, because a predictable invite is staff access to anyone who guesses), lives forInviteTTL = 48 hours, and is returned in thePOST /apps/loyalty/staff/inviteresponse exactly once — the entity'sinvite_tokenfield isjson:"-", so the list endpoint cannot leak it at all. The comment states the principle: "a secret that can be re-read is a secret sitting in every future response." Lose the link and the only path is revoke-and-re-invite. The frontend never shows the bare token either — it builds the link, and errors out rather than showing anything if the OA's hash isn't known yet. - Deleting an invite is a real delete; revoking a staff member is not — and the line is "does history hang off it yet?" —
RevokeInvitedeletes the row outright, but only rows still at statusinvited, because an unclaimed invite has no history attached and nothing to preserve. A real staff member is set torevokedand their row survives, so past issuances stay attributable and a revoked cashier re-opening the staff LIFF stays revoked instead of landing back in the approval queue as a fresh request. Approving a row still atinvitedis rejected with a plain 400 — there's no LINE identity to approve yet (and the CHECK constraint would refuse an active row without one anyway) — chosen over surfacing a 500. - Tier rank is enforced by a unique index, not a pre-check — the comment notes a pre-check would be a race between two open tabs, so the index does the work (partial on
status = 'active', which is exactly why archiving a tier frees its rank for reuse) and a unique violation (23505) is mapped toLOYALTY_TIER_RANK_TAKEN. It's also recorded that only a unique violation maps to that code, because mapping every error there hid the real cause once already — a broken column name reads as "pick another number", which sends the merchant hunting the wrong thing. - Only a
spendrule requires point mode —validateTierInputreturnsLOYALTY_TIER_POINT_MODE_ONLYonly when aspendrule appears in a non-pointprogramme. Order count and membership age work fine on a stamp card, so refusing the whole feature there would be arbitrary. That validator is shared by the create and update paths so that a rule the create path refuses cannot become reachable by saving an edit. - "Calculate now" uses the nightly job's rules in every respect, demotions included —
RecalculateTiersrequires point mode and tiers enabled, or it's a 400. An empty ladder returns{evaluated: 0, changed: 0}instead of demoting everybody (the comment: "demoting everybody off an empty ladder would be a startling way to react to a half-finished setup"). The subtle part is that it computes stats per tier, because each tier may override its own window — a ladder mixing "5,000 in 12 months" with "500 in 3 months" needs both totals — and the query uses aLEFT JOINso accounts with no spend in the window arrive at zero rather than disappearing, since those are precisely the ones due a demotion. The worker-go nightly job at 03:00 remains the thing that keeps tiers honest; this button exists because a merchant who has just built a ladder needs to see whether it does what they meant, and "check tomorrow" is not an answer while they're still deciding the thresholds. (The comment also notes this is the third copy of the tier-evaluation logic — client-api promotes at the till, worker-go sweeps nightly, this recalculates on demand, and the three Go modules share no package.) - The welcome bonus isn't a column on the programme table — it's folded to look like one — it's a
bonus_rulerow (trigger_type = 'join',effect = 'grant_units',cadence = 'once'), and the controller folds it into the same payload as the programme underwelcomeUnits, because "it lives in its own table, but to a merchant it is a field on the card — the form should not have to know it is a bonus_rule row." It's saved only when the caller actually sent it, so a partial edit of the card cannot silently clear it, and 0 or negative removes the rule. - The activity log's row cap lives in the backend, not the UI — the frontend asks for 200, but
Activityclamps:limit <= 0 || limit > 500becomes 100. Staff / branch / customer names are JOINed rather than denormalised onto the transaction, so a staff member renamed in the CMS reads correctly on rows issued before the rename — and theline_userjoin is scoped byline_oa_idas well asuser_id, because a LINE userId is global and joining on it alone would pull a profile from another OA's rows. - The 20-slot card cap is a backend decision, not a LINE limit —
MaxCardSize = 20, with the comment noting LINE allows 50 but a 50-slot grid is unreadable on a phone. The value matches the database CHECK constraint and the client API.
Related menus in this journey
- Segment audience > Multi-source filter — the destination for the preset segments. The Segments tab doesn't build audiences itself; it hands off the condition set (source type
loyaltywith metric/operator/value) plus a group name as apresetquery parameter to/audience/filter/multi-source/form/?id=create&preset=.... The receiving form applies it once, puts all sources into a single group, sets the title from the preset name, and shows a"Using a saved preset: {name}"banner — everything stays editable from there. In the other direction, that filter page pulls this app's tier ladder in as selectable options. - Content Management — this app has no image upload endpoint of its own. The card cover, logo, stamp icon, reward images and tier background images all go through the Content Management module's uploader (
CONTENT_PAGE_SERVICE.uploadImage→POST /content-pages/upload-image), which accepts JPEG / PNG / GIF only (SVG is deliberately refused). Tier background images additionally get a plain border trimmed off client-side before upload. - Apps (platform admin console) — the only place this app can be switched on or off for an organization.
Things to watch out for
- Changing the programme mode (stamp ↔ point), or the slot count, publishes a new version — old balances stop counting (they are not deleted, and come back exactly as they were if you switch back), and the reward ladder is replaced with the new unit's prices. The system always warns about all 3 consequences before saving. Editing the name / colours / design / terms does not version, but does reach every customer immediately.
- Reduce the slot count and rewards past the new last slot vanish from the new version — you'll need to re-create them; the system won't move them inward for you.
- The staff invite link is shown once, works once, and expires in 48 hours — if you forget to copy it or lose it, the only path is cancelling the invite and issuing a new one. There's no way to view it again.
- Inviting a staff member doesn't grant access immediately — it takes two steps: they open the link to identify themselves (
pending), then an admin approves them (active). - "Calculate now" only works in point mode with tiers enabled and at least one tier defined — with an empty ladder it reports 0 customers checked rather than demoting everyone.
- Actual earning and redeeming don't happen in this CMS — they happen in the staff and customer LIFF pages. This surface configures the programme and reads its history back, and there is no manual point-adjustment endpoint anywhere in this module.
- The activity log shows at most 100 rows in practice, even though the UI asks for 200, because the backend clamps anything out of range back to 100.
Access control (verified against the source)
- Backend —
AppEnabledGuardis genuinely chained onto all 22 routes in this module.loyalty/controller.gobuildsappEnabled := apps.AppEnabledGuard(d)and puts it as the first middleware on every single line. The guard (apps/guard.go) extracts the app id from the path with the regex/apps/(\w+)and callsIsAppEnabled(orgID, "loyalty"), which looks for aline_oa_approw whereorganization_id = ? AND app_id = ? AND enabled = true— no row means disabled — returning 403App "loyalty" is not enabled for this organization. Note that this guard scopes by organization_id only, not per LINE OA. - Enabling/disabling the app is a platform-admin-only endpoint.
PUT /api/apps/:appIdis wrapped inmodulegate.PlatformOnly(d), which reads the caller'suser.typefrom the database and rejectscustomer-type callers outright. The stated reason: if an org could toggle its own apps, it could re-grant something the platform admin had withheld. There's a deliberate asymmetry — a normal module'sModuleGatefails open, whereasPlatformOnlyfails closed (a caller we can't identify is treated as a customer and denied), because the failure mode here is privilege escalation, not lockout. - The action-level policy annotations in the code enforce nothing. Every route declares
auth.CheckPolicies(auth.Can(..., auth.PolicyModuleLineOa)), butauth.CheckPoliciesis a genuine no-op (internal/modules/auth/policy.goreads "Enforcement disabled — allow all authenticated requests (TS parity)"). So: the only real enforcement layer on this module is the app-levelAppEnabledGuard. There is noModuleGatehere the way Campaign has one, and no separation between whoever configures the programme and whoever approves staff — anyone who can reach the app can do everything in it, including minting staff invite tokens. - Cross-tenant separation is enforced at the query layer. Every query goes through
repo.scoped/repo.tx(loyalty/scope.go), constraining bothorganization_idandline_oa_id, and each raw SQL statement inRecalculateTiers/DeleteTiercarriesline_oa_idin its own WHERE clause. - The split with the client API is an additional architectural control. The CMS configures and reads; actual earning and redeeming live in the client API and the staff LIFF. The practical consequence is that even a compromised CMS account has no endpoint in this module that can add points to a customer's account.
Source file reference
Frontend (cms-web-2026)
src/app/apps/loyalty/layout.tsx— the 8-tab bar and the programme-mode badgesrc/app/apps/loyalty/card/page.tsx— the mode switch, the form's limits, and the 3-consequence confirmation on savesrc/app/apps/loyalty/design/page.tsx+components/full-card-preview.tsx— images/colours and the full card previewsrc/app/apps/loyalty/rewards/—page.tsx,reward-form.modal.tsx(units and caps that change with the mode)src/app/apps/loyalty/tiers/—page.tsx(the recalculate button),tier-form.modal.tsx(metric/operator rules),lib/describe-rule.ts,lib/trim-border.tssrc/app/apps/loyalty/segments/—page.tsx,lib/presets.ts(the 9 presets and how thepresetquery is built)src/app/apps/loyalty/staff/page.tsx— the invite flow, the shown-once link, and the staff status lifecyclesrc/app/apps/loyalty/{activity,customer}/page.tsx— the activity log and customer searchsrc/services/loyalty.service.ts— every endpoint the frontend calls
Backend (line-management-cms-api-go)
internal/modules/apps/guard.go—AppEnabledGuardand theline_oa_applookupinternal/modules/apps/controller.go—PUT /apps/:appIdwithmodulegate.PlatformOnlyinternal/modules/modulegate/modulegate.go—PlatformOnly, which fails closedinternal/modules/loyalty/controller.go— all 22 routes and theAppEnabledGuardon eachinternal/modules/loyalty/service_program.go—IsStructural, versioning,copyChildren, the mode-driven overrides,SetStatusinternal/modules/loyalty/service_catalog.go— rewards, branches, staff/invites (InviteTTL,randomToken), the welcome bonus, tiers,RecalculateTiers, the activity log, customer lookupinternal/modules/loyalty/scope.go— the two-column scoping every query in the module goes through
See the documentation team's earlier technical write-up at Loyalty App