Loyalty App
Overview
Loyalty is one of the Apps — optional modules that a platform administrator enables or disables per organisation, unlike the rest of the CMS, which is governed by role-based permissions.
The app lets a merchant design a loyalty card for customers to use through LINE, in one of two modes:
- Stamp mode — the card has a fixed number of slots (5, 10, or 15). Staff scan a QR code to award one stamp per purchase, with no monetary amount recorded.
- Point mode — customers earn points proportional to what they spend, with a configurable baht-per-point rate, and membership tiers can be switched on.
Three groups of people are involved: the merchant or CMS administrator, who configures everything; counter staff, who never touch the CMS but receive a single-use invitation link that opens in the customer-facing web app; and customers, who open their card through a LIFF link.
The interface is organised into eight tabs: card rules, card appearance, rewards, membership tiers, preset segments, staff and branches, activity log, and customer lookup.
Business Flow
Access and app visibility
- The side menu queries the app registry on load and keeps only the enabled entries. If Loyalty is among them, a submenu item is added; the parent "Apps" menu appears only when at least one app is enabled.
- Menu visibility does not depend on the user's role or permissions at all — only on the app registry.
- Every route under
/appssits behind a shared route guard that reads the app ID from the URL and checks it against the registry. A disabled app redirects home; an ID absent from the registry is treated as an ungoverned path. - If the registry call fails, the guard allows access. The server still protects the data, and a temporary registry outage should not lock users out of an app they legitimately have.
- The guard is necessary because although the API rejects requests and the menu hides disabled apps, nothing otherwise stops someone typing the URL directly — which would render an empty shell where every call fails.
- The Loyalty layout owns the eight tabs, the breadcrumb, the active menu state, and a badge showing the programme mode. That badge refreshes both on navigation and whenever the card rules screen signals that new settings were saved.
Card rules
- The screen loads the programme configuration into the form and shows a status tag (draft, active, or paused) alongside the version number.
- The mode is chosen with a segmented control and can be switched freely, since nothing has been written to the database yet. Switching to point mode resets an expiry style that does not exist in the new mode.
- Visible fields change with the mode. Stamp mode offers the card size, three expiry styles, and a lower ceiling on welcome units. Point mode offers the baht-per-point rate, two expiry styles, and a switch for membership tiers along with the demotion rule.
- Some fields are conditional: the expiry duration appears only when expiry is enabled, and the cooldown hours appear only when the cooldown is set to a per-hours mode.
- Saving after a mode change opens a confirmation dialog explaining three consequences: a new version will be issued, existing balances stop accruing but are not deleted, and the reward ladder is replaced by the pricing of the new mode.
- The server decides on its own whether to edit in place or issue a new version, based on the shape of the change. The client cannot request an in-place structural change. This is the mechanism that keeps cards already in customers' hands on their original terms.
- Publishing and pausing are separate actions, and the publish button stays disabled until a programme actually exists.
- The right column shows the customer card links and a preview card that points to the appearance screen.
Customer card links
- The screen reads the current LINE OA from the profile and fetches the OA record once per mount to obtain the hash and LIFF ID, since neither is present in the cached profile.
- Three link shapes can be produced: the primary card link into the customer web app, a LIFF variant for use in a rich menu slot, and staff invitation links carrying a token.
- Without an OA hash, every link resolves to nothing and a warning is shown. A bare token is never displayed as a substitute, because a token is not a usable link.
Card appearance
- Three datasets load in parallel: the programme configuration, the reward list, and the tier list — tiers may not exist at all in stamp mode.
- Uploadable images are a wide cover, a round logo, and a stamp icon (stamp mode only).
- Four colours are adjustable: the accent, the text colour, the button colour, and the stamp fill (stamp mode only).
- Image upload borrows the content management module's service and accepts JPEG, PNG, and GIF only. SVG is rejected server-side deliberately, as an XSS vector.
- The right column renders a scrollable full-card preview including rewards and terms. With tiers enabled, a selector lets the merchant view each tier's card and see the spend required to reach the next one.
- Appearance edits are cosmetic and never produce a new version, so there is no confirmation dialog when saving.
Rewards
- The table lists the units required, the name with its image, and a members-only column derived from the minimum tier — shown only in point mode when tiers actually exist.
- The first column header changes meaning by mode: "price" in point mode, "at slot" in stamp mode.
- The ceiling on required units differs by mode. Point mode allows up to one hundred thousand; stamp mode is capped at the card's slot count, because a reward beyond the last slot can never be reached.
- The tier gate control appears only when all its preconditions are met; when they are not, the value is always submitted as empty, so a stale value cannot linger invisibly.
- This table is unpaginated, and deletion uses a short inline confirmation.
Membership tiers
- The screen shows two context-dependent warnings: that spend-based rules do not apply to stamp cards (other metrics still work, so the tab is not disabled outright), and that the tier ladder has not yet been switched on.
- The table shows the rank (with 1 as the lowest tier), the name with its colour dot, and a rules column that renders each condition as readable prose with tags indicating whether they combine with "and" or "or".
- A tier with no rules is flagged in red, because the evaluation engine reads it as "nobody qualifies" rather than "everybody qualifies".
- A recalculate button re-evaluates every member immediately and reports how many were evaluated and how many changed tier. A scheduled job already does this at 3 a.m. daily; the button exists so results can be seen at once.
- The tier form covers the name, rank, tier and text colours, a card background image, a default-tier switch, and the rule set.
- Background images have their uniform borders cropped automatically before upload, and when a crop actually occurs the screen reports how many pixels were trimmed.
- Enabling the default-tier switch hides the rules section with an explanatory note. Each rule picks a metric (spend, available in point mode only; order count; points; or months of membership), an operator, and a comparison value.
- Deleting a tier archives it rather than removing it, since customer history and accounts still reference the record.
Preset segments
- The screen filters its preset conditions by the programme mode; presets that name no mode work in both.
- Stamp-mode presets focus on collection behaviour — sitting on unredeemed rewards, almost complete, regulars, and new collectors. Point-mode presets focus on value — big spenders, top tier, enough points to redeem, and a growing group. One preset, lapsing customers, applies to both.
- Each card shows the name, description, use case, and the conditions translated into readable phrases.
- This screen does not create the audience itself. It encodes the conditions as parameters and hands them to the standard multi-source audience form, where every control remains available for further editing.
Staff and branches
- The screen loads the staff list and the branch list in parallel, and warns when staff are awaiting approval.
- Onboarding is invite-only. The merchant enters a name and picks a branch (with "all branches" presented as an explicit option, not as a blank), and the system mints a token.
- The token is returned exactly once and cannot be read back, so the screen assembles it into a link and presents it in a modal with a copy control and a persistent warning to copy it now. If no link can be assembled, an error is shown — never a bare token.
- The add-staff button stays disabled while the link data is still loading, so a real token is never minted with no link to display.
- Staff move through four states: invited, then pending once the link is clicked, then active once an administrator approves, and finally revoked. A claimed invite always lands on pending rather than active, because the link may have been forwarded.
- The table shows both the label the merchant assigned and the LINE display name of whoever actually claimed the link; a mismatch is a signal that the link was passed on.
- Available actions change with the state, and a staff member's branch can be reassigned directly from the table. Branches are managed through a short inline form on the same screen.
Activity log and customer lookup
- The activity log fetches the 200 most recent entries at once and paginates them client-side. Columns cover the timestamp, activity kind, units added or removed, amount spent (point mode only), who awarded it, the branch, the card number, and the customer. This screen has no filters or search.
- Activity kinds are translated into readable coloured tags — reward redemption, expiry, staff scan, welcome bonus, and manual CMS adjustment among them.
- All unit labels use the merchant's own wording rather than a fixed "stamp", because the old fixed label led point-mode merchants to assume they were looking at a feature they had turned off.
- The customer lookup screen has a single search box accepting a phone number, name, or email. A single matching result opens straight into the detail view with no extra click.
- The detail view shows profile information from the LINE user database (which predates any loyalty account), summary figures that change by mode, and the transaction history. If the customer has no loyalty account yet, an empty state is shown instead.
- This screen is read-only. There is no button to adjust or grant points manually from the CMS, even though the activity log includes a manual-adjustment kind.
Key Screens & Components
Shared route guard (src/app/apps/layout.tsx) — verifies that the app in the path is enabled, with fail-open behaviour on error.
App layout (src/app/apps/loyalty/layout.tsx) — the eight tabs, breadcrumb, active menu state, and programme mode badge.
Tab pages (src/app/apps/loyalty/*/page.tsx) — card rules, appearance, rewards, tiers, preset segments, staff, activity, and customer lookup. The reward and tier forms live in their own modal files.
App-local libraries — the rule-to-prose translator (tiers/lib/describe-rule.ts), the image border cropper (tiers/lib/trim-border.ts), the preset definitions (segments/lib/presets.ts), and the card link builder (lib/use-loyalty-links.ts).
Preview components — the card links card, the compact card preview, and the full card preview used by the appearance screen and elsewhere.
Services — src/services/loyalty.service.ts covers every operation of the app: programme, rewards, tiers, branches, staff, activity, and customer lookup. src/services/apps.service.ts is read-only by design, since enabling and disabling apps belongs to the platform administrator.
Dependencies
- App registry and platform administrator — decide whether the app appears at all. The CMS can only read the registry; enabling and disabling happen in the platform admin console.
- Permissions — subjects for the app are declared in the enum but have no backend mapping, so they serve only as menu and breadcrumb keys. Real access control comes from the registry plus the API-side guard.
- Customer web app — hosts the customer card page and the staff page, both of which initialise LIFF themselves.
- LINE OA Management — the source of the OA hash and LIFF ID used to build every link.
- Audience — the preset segments screen hands its conditions to the multi-source audience form.
- Content Management — the app borrows this module's image upload service, having none of its own.
- Scheduled jobs — a nightly job at 3 a.m. re-evaluates membership tiers; the recalculate button runs the same work on demand.
- LINE user database — profile data in the customer lookup and the names and avatars in the activity log are joined live from this table.
- Internal architecture — unlike the rest of the CMS, every screen in this app calls its services directly and manages its own state rather than going through the shared request layer.
Backend Details (CMS API)
The module lives in internal/modules/loyalty/. Every endpoint sits under /api/apps/loyalty/ and must first clear the add-on app machinery.
How the app platform controls access
- Any request whose path matches
/apps/followed by an app name is intercepted by theAppEnabledGuard, which extracts the app ID from the path and checks whether the organisation has that app enabled. If not, the request is rejected with a 403 saying the app is not enabled for this organisation. - If the organisation ID cannot be read from the context, the guard treats the app as disabled rather than enabled — it denies on incomplete information.
- Only platform administrators can enable or disable an app.
PUT /api/apps/:appIdis wrapped in a platform-level check; a customer-side caller, or one whose identity cannot be established, gets a 403. This check fails closed, unlike the system's general module gate which fails open when it cannot read its data. The reasoning is that the risk here is privilege escalation: if an organisation could enable apps for itself, the governance model would be meaningless. GET /api/apps, which the frontend calls right after login to render the menu, uses a login-level token check rather than the full guard, because at that moment the token is not yet bound to a LINE OA. With the stricter guard it would return 401 and the frontend would immediately log the user out.- Note: the policy metadata for
GET /api/appsandPUT /api/apps/:appIdis declared against the friend-track module. That is carried over from the previous system rather than a porting mistake, but it is a coupling the names do not suggest and deserves care before permissions are reworked. - Inside the loyalty module itself, policies are declared against the
line-oamodule and split by action (read/readAll/create/update/delete), but the check that actually bites in practice is theAppEnabledGuard.
Business rules the backend really enforces (with error codes)
The module uses the same error code set as the client-api, so the back office and the customer-facing app speak the same language. The codes worth knowing:
LOYALTY_PROGRAM_MISSING— reading the program before one has ever been configured returns an error, not an empty value, so the UI must distinguish "not yet configured" from a genuine failure.LOYALTY_CARD_SIZE_INVALID— the submitted card size is invalid; checked when the program is saved.LOYALTY_TIER_POINT_MODE_ONLY— membership tiers only exist in points mode. If the program is set to stamp mode, creating or editing a tier is rejected server-side, not merely hidden in the menu.LOYALTY_TIER_INVALID— the tier's numbers do not make sense.LOYALTY_TIER_RANK_TAKEN— the tier rank collides with an existing one; ranks must be unique.
Tier recalculation
POST /api/apps/loyalty/tiers/recalculate re-evaluates membership tiers immediately, in addition to the nightly 3 a.m. job that runs on the worker. The button exists because a shop that has just finished configuring its tier ladder should not have to wait until the next morning to see whether the configuration is right. Both paths run the same work.
What the CMS cannot do
Awarding points and redeeming rewards do not happen in the CMS API — they happen in the client-api through the staff and customer LIFF pages. This back office configures the program, the reward ladder, branches, and the authorised staff list, then reads the history back. Staff onboarding works through invitations, so deleting a staff row means revoking an invitation.
Tables involved
The loyalty table group (program, milestones, tiers, branches, staff, transaction history, membership records) together with line_oa_app (the rows recording which apps an organisation has enabled), line_oa, and line_user.