Skip to main content

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.

  1. resolve(c) verifies the LIFF token against the OA's channel, then looks up the loyalty.staff row for that LINE user.
  2. Not found → {status:"unknown", displayName: from the token, branches:[]}.
  3. Found → returns status, branchId, and displayName.
  4. branches, mode, unitLabel, and bahtPerPoint are sent only when st.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.

  1. An empty token returns 400 LOYALTY_INVITE_INVALID.
  2. Someone who is already staff gets 409 LOYALTY_ALREADY_STAFF without burning the invitation — what they need to do is nothing at all.
  3. ClaimInvite(oaID, token, lineUserID, name, "") runs.
  4. 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.
  5. 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?}

  1. Look up the staff row and the live program.
  2. If units is 0 and the program is not in point mode, it defaults to 1.
  3. IssueToken validates in order:
    • A program that is not live returns 400 LOYALTY_PROGRAM_INACTIVE; inactive staff get 403 LOYALTY_STAFF_NOT_ACTIVE.
    • Point mode requires amountSpent (missing → 400 LOYALTY_AMOUNT_REQUIRED), and the server converts it to points itself via UnitsForSpend(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 400 LOYALTY_RATE_NOT_SET; a genuine purchase that does not reach one point returns 400 LOYALTY_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 amountSpent and uses the units the 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 use MaxEarnUnits (10000 — at 20 baht per point, a ฿2,000 basket is 100 points, an ordinary purchase). Anything out of range returns 400 LOYALTY_TOKEN_INVALID.
    • Staff without rights at that branch (MayUseBranch) get 403 LOYALTY_BRANCH_INVALID; a branch that does not exist returns 400 LOYALTY_BRANCH_INVALID.
  4. InsertToken(..., TokenTTL) mints a QR valid for 60 seconds — short enough that a screenshot is worthless.
  5. The response is {token, units, amountSpent, expiresAt}.

Scan a coupon — POST /api/loyalty/:hash/staff/redeem (rate limit 30/60s)

Body {code}

  1. Staff must pass CanIssue(), otherwise 403 LOYALTY_STAFF_NOT_ACTIVE.
  2. The code is upper-cased and trimmed; empty returns 400 LOYALTY_REWARD_INVALID.
  3. ClaimReward is a conditional UPDATE inside a transaction; success returns the reward.
  4. 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 → 409 LOYALTY_REWARD_CLAIMED; anything else → 409 LOYALTY_REWARD_EXPIRED.
  5. 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

RouteRate limitHandler
GET /api/loyalty/:hash/staff/me(*Handler).StaffMe
POST /api/loyalty/:hash/staff/claim5/60s(*Handler).StaffClaimInvite
POST /api/loyalty/:hash/staff/tokens30/60s(*Handler).StaffToken
POST /api/loyalty/:hash/staff/redeem30/60s(*Handler).StaffRedeem
GET /api/loyalty/:hash/staff/activity(*Handler).StaffActivity
  • internal/loyalty/service.go(*Service).IssueToken, (*Service).RedeemReward
  • internal/loyalty/repository.goFindStaffByLineUser, ClaimInvite, ListBranches, FindBranch, InsertToken, ClaimReward, FindRewardByCode, StaffActivityToday
  • internal/loyalty/entity.goStaff.CanIssue(), Staff.MayUseBranch(), EarnToken, UnitsForSpend, TokenTTL, MaxCardSize, MaxEarnUnits, and the error codes LOYALTY_STAFF_NOT_ACTIVE, LOYALTY_INVITE_INVALID, LOYALTY_ALREADY_STAFF, LOYALTY_BRANCH_INVALID, LOYALTY_AMOUNT_REQUIRED, LOYALTY_RATE_NOT_SET, LOYALTY_SPEND_TOO_SMALL
  • internal/loyalty/view.goStaffView, 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