Loyalty — Staff Tools
Overview
Five endpoints for staff behind the counter. They run inside the same LIFF app as the customer view
and use identical authentication — being staff is not a different kind of token, it is having a
row in loyalty.staff bound to that LINE user, with each handler checking its own allowlist.
The feature covers checking one's own status, accepting a staff invitation, issuing a QR for the customer to scan, scanning a coupon to burn a reward, and reviewing one's own activity for the day.
Business Flow
Staff status — GET /api/loyalty/:hash/staff/me
The endpoint that decides which screen the staff app shows.
resolve(c)verifies the LIFF token against the OA's channel, then looks up theloyalty.staffrow for that LINE user.- Not found →
{status:"unknown", displayName: from the token, branches:[]}. - Found → returns
status,branchId, anddisplayName. branches,mode,unitLabel, andbahtPerPointare sent only whenst.CanIssue()is true. The branch list discloses the size of the business to someone with no right to issue points, and mode/rate exist for the till screen, which only issuing staff ever see.
Accept an invitation — POST /api/loyalty/:hash/staff/claim (rate limit 5/60s)
Body {token}. This carries the strictest rate limit in the module because it is the only endpoint
reachable by someone who is not yet staff, making it the place tokens get guessed. It replaced an
older open "request to become staff" endpoint: today the owner identifies the person first, then sends
them a single-use link.
- An empty token returns 400
LOYALTY_INVITE_INVALID. - Someone who is already staff gets 409
LOYALTY_ALREADY_STAFFwithout burning the invitation — what they need to do is nothing at all. ClaimInvite(oaID, token, lineUserID, name, "")runs.- An unknown token, an expired one, and an already-claimed one collapse into a single code,
LOYALTY_INVITE_INVALID. To the person holding the link all three are the same situation, and distinguishing them would confirm which tokens exist. - On success the response is
{status}, the state of the resulting staff row.
Issue a QR — POST /api/loyalty/:hash/staff/tokens (rate limit 30/60s)
Body {branchId, units, amountSpent?}
- Look up the staff row and the live program.
- If
unitsis 0 and the program is not in point mode, it defaults to 1. IssueTokenvalidates in order:- A program that is not live returns 400
LOYALTY_PROGRAM_INACTIVE; inactive staff get 403LOYALTY_STAFF_NOT_ACTIVE. - Point mode requires
amountSpent(missing → 400LOYALTY_AMOUNT_REQUIRED), and the server converts it to points itself viaUnitsForSpend(bahtPerPoint, amount). The staff device does not get to do that arithmetic: a client sending units directly can send any number, and the rate is a shop setting, not a phone setting. No rate configured, or an amount not above zero, returns 400LOYALTY_RATE_NOT_SET; a genuine purchase that does not reach one point returns 400LOYALTY_SPEND_TOO_SMALL— it gets its own code because "spend a little more" is a different message from "the system is broken". - Stamp mode ignores
amountSpentand uses theunitsthe staff member typed: one cup of tea is one stamp regardless of price. - The upper bounds differ because the unit means different things. Stamps use
MaxCardSize(20 — a grid that fits a phone screen; a typo of 500 would hand out 50 cards in one scan), while points useMaxEarnUnits(10000 — at 20 baht per point, a ฿2,000 basket is 100 points, an ordinary purchase). Anything out of range returns 400LOYALTY_TOKEN_INVALID. - Staff without rights at that branch (
MayUseBranch) get 403LOYALTY_BRANCH_INVALID; a branch that does not exist returns 400LOYALTY_BRANCH_INVALID.
- A program that is not live returns 400
InsertToken(..., TokenTTL)mints a QR valid for 60 seconds — short enough that a screenshot is worthless.- The response is
{token, units, amountSpent, expiresAt}.
Scan a coupon — POST /api/loyalty/:hash/staff/redeem (rate limit 30/60s)
Body {code}
- Staff must pass
CanIssue(), otherwise 403LOYALTY_STAFF_NOT_ACTIVE. - The code is upper-cased and trimmed; empty returns 400
LOYALTY_REWARD_INVALID. ClaimRewardis a conditional UPDATE inside a transaction; success returns the reward.- On no match the endpoint explains why, but only for rewards belonging to this OA, so a code
from another tenant is answered with "unknown" rather than confirmation that it exists somewhere.
Missing → 404
LOYALTY_REWARD_INVALID; already used → 409LOYALTY_REWARD_CLAIMED; anything else → 409LOYALTY_REWARD_EXPIRED. - The response is
{redeemed:true, rewardId}.
Today's activity — GET /api/loyalty/:hash/staff/activity
Requires CanIssue(), otherwise 403 LOYALTY_STAFF_NOT_ACTIVE. Returns {items} from
StaffActivityToday(staffID) — that staff member's own record, for accountability.
Key Files & Functions
| Route | Rate limit | Handler |
|---|---|---|
GET /api/loyalty/:hash/staff/me | — | (*Handler).StaffMe |
POST /api/loyalty/:hash/staff/claim | 5/60s | (*Handler).StaffClaimInvite |
POST /api/loyalty/:hash/staff/tokens | 30/60s | (*Handler).StaffToken |
POST /api/loyalty/:hash/staff/redeem | 30/60s | (*Handler).StaffRedeem |
GET /api/loyalty/:hash/staff/activity | — | (*Handler).StaffActivity |
internal/loyalty/service.go—(*Service).IssueToken,(*Service).RedeemRewardinternal/loyalty/repository.go—FindStaffByLineUser,ClaimInvite,ListBranches,FindBranch,InsertToken,ClaimReward,FindRewardByCode,StaffActivityTodayinternal/loyalty/entity.go—Staff.CanIssue(),Staff.MayUseBranch(),EarnToken,UnitsForSpend,TokenTTL,MaxCardSize,MaxEarnUnits, and the error codesLOYALTY_STAFF_NOT_ACTIVE,LOYALTY_INVITE_INVALID,LOYALTY_ALREADY_STAFF,LOYALTY_BRANCH_INVALID,LOYALTY_AMOUNT_REQUIRED,LOYALTY_RATE_NOT_SET,LOYALTY_SPEND_TOO_SMALLinternal/loyalty/view.go—StaffView,BranchView,TokenView,NewBranchViews
Connections to Other Services
- Tables
loyalty.staff,loyalty.branch,loyalty.earn_token,loyalty.reward,loyalty.transaction,loyalty.program,line_oa - Staff invitations are created in the CMS (cms-api-go); these endpoints only handle the claim side
- Tokens issued here are consumed by loyalty-earn; the scanned codes originate in loyalty-reward-redeem
- app-enabled-guard covers the staff side as well
- Corresponding client-web feature:
loyalty-staff