ProjectR Docs
Backend (Convex)

Loyalty

Stamp campaigns, cycles, shake-to-win, and the points and tiers removal.

Files: convex/gamificationLoyalty/*, convex/mobileLoyalty/*.

The loyalty program is stamps only. A paid order earns stamps on every active campaign whose minimum spend it meets, and special stamp positions pay out shake-to-win vouchers. Points and tiers were removed on 2026-10-16 (see Points and tiers removal).

Stamp campaigns

stampCampaigns define totalStamps (1–24), stampsPerPurchase, minPurchaseAmountCents, and specialPositionsConfig (per-position shake reward tiers). evaluateStampEligibility in gamificationLoyalty/stampOutcome.ts is the only minimum-spend check. It compares minPurchaseAmountCents with the order subtotal in cents, after discounts and before shipping and tax, and returns the shortfall when the order falls short. The mobile bag runs a copy of the same function, and a test keeps the two in step. A normalized campaignSpecialPositions table is written alongside, but the reward engine reads only campaign.specialPositionsConfig.

Cycle math (computeStampPlan, pure)

  • The cycle-completing stamp lands on position totalStamps (never 0).
  • currentStamps stores only the remainder.
  • completedCycles is a monotonic lifetime counter (analytics read it).
  • Redemption consumes redeemedCycles; redeemable = completedCycles − redeemedCycles.

awardStampsForPurchase inserts one stampHistory row per stamp, evaluates a special reward at every crossed position, maintains the campaignStamps aggregate per row, patches progress once at the end, and dedups by (orderId, campaignId). Multi-stamp awards that cross a special position still grant it.

Shake-to-win

Rewards are instant at stamp collection. calculateShakeReward draws from probability tiers (tierOrder, summing to 100%, validated on write) using cryptoRandomFloat(). Reward tier types are VOUCHER and MISS. A POINTS tier saved before the removal grants nothing, and calculateShakeReward logs a warning when it draws one. Voucher wins insert a vouchers row immediately (source shake_reward), enqueue Shopify code creation, and bump the counter. The mobile "shake" animation is purely visual.

shakeOpportunities (pending/claimed/expired) is vestigial — the instant-reward design replaced it; no production code inserts rows.

Safety rails

  • collectStamp rate limit: 1/min per (userId, campaignId).
  • collectStamp returns a uniform failure result instead of throwing, so one campaign's failure can't sink the workflow.
  • mobileCollectStamp is internalMutation on purpose — it used to be public and let clients mint stamps with a self-supplied amount.
  • resetUserProgress must zero redeemedCycles too, or the negative redeemable balance locks the user out.
  • NaN guard before stamp math (Number.isFinite) — NaN passes <= 0 checks and poisons progress.

Points and tiers removal

Points and loyalty tiers came out on 2026-10-16. Nobody had ever spent a point (prod showed zero redemptions), so the whole surface went. Orders earn no points, checkout reserves none, admins cannot adjust a balance, and the dashboard lost its tier badges, tier filters and carousel tier targeting. Shake-to-win reward tiers are a different thing and stay.

  • Dashboard queries return no points or tier keys. The dashboard on main before this release still reads them, so its backend deploys only after Vercel has rebuilt the dashboard (step 6 below).
  • orders.pointsEarned is the legacy done marker. A non-null value, including 0, still means the order's rewards run finished. isOrderRewardsSettled honours it next to rewardsProcessedAt.
  • campaign.rewardType is voucher or product.
  • The schema no longer defines the five tables loyaltyPoints, pointsTransactions, loyaltyTiers, userTiers and loyaltySettings, the userProfiles.loyaltyPointsBalance and userProfiles.loyaltyTier fields, or the checkout-session reservation fields. Convex refuses a schema that drops a field documents still hold, so a deployment that still has points data needs two deploys.

Dev was done on 2026-10-16. For staging and prod, from packages/backend:

  1. Deploy commit f3c378a1. It runs the stamps-only code on the old schema, still ships migrations/removePoints.ts, and still serves the old dashboard.
  2. bash scripts/archive-points-tables.sh <staging|prod> writes every row of the five tables to JSONL outside the repo. It exits 2 when a table hits the export limit, and then bunx convex export is the fallback. The archive is customer data, so never commit it.
  3. bunx convex run migrations/removePoints:removePointsData '{"archived": true}' deletes the rows and unsets the leftover profile, checkout and campaign fields. Add --prod, or run it through the staging env file, for those deployments. It refuses to run without archived: true, and its probe.ok must be true.
  4. Deploy commit feca3de3, the schema drop. Deploying it before step 3 fails safely, because Convex rejects the schema and changes nothing.
  5. Run migrations/orderStamps:fillOrderStampFields '{}'.
  6. Rebuild the dashboard from this release. For prod, merge the develop to main PR so Vercel rebuilds. For staging, push staging.
  7. Deploy develop HEAD, which drops the dashboard compatibility keys.

On this page