Scout — Full Product Context → feature documentation

Achievements

Achievements is a badge/trophy system layered on top of Scout's existing patch-collecting mechanic.

Summary

Achievements is a badge/trophy system layered on top of Scout's existing patch-collecting mechanic. It shipped in PR #188, "Achievements: 144-achievement catalog, in-person earning, and Wild Cards" (a852f89b, merged 2026-08-31). A catalog of 144 achievement rows lives in the achievements table, split across six "families" (type, volume, geo, app, behavior, secret). Achievements are earned automatically — evaluated server-side, either when a patch is collected/uncollected (POST /api/sync/push) or when the client records a one-shot "feature event" (POST /api/feature-events, e.g. opening a screen for the first time). Earning pays XP and can advance a daily streak (UserProgression). Only patches collected in person (GPS-sourced, not imported from the camera roll) count toward any patch-derived achievement.

Approval model: every one of the 144 seeded achievements is created with isApproved: false, and GET /api/achievements returns only rows where isActive && isApproved. Achievements are therefore gated by content, not by a feature flag — there is no boolean anywhere that turns "Achievements" globally on; each row is approved individually in the admin UI and published to prod, and approval is retroactive. Achievements are approved and LIVE in production today (confirmed by the product owner, 2026-08-31); the false default describes what a fresh seed produces, not the current prod state.

Status (shipped / beta-badged / flagged off)

User-facing surfaces

How it works (end-to-end mechanism)

  1. Trigger. Either the user collects/uncollects a patch (mobile calls POST /api/sync/push, handled by SyncService.awardAchievements in backend/src/sync/sync.service.ts:653-670), or the client records a feature event (POST /api/feature-events, backend/src/achievements/feature-events.controller.ts). Both paths call AchievementsService.award(userId, { announce }).
  2. Context assembly. award() (backend/src/achievements/achievements.service.ts:33-150) pulls, in parallel: all active+approved Achievement rows; the user's UserPatch rows filtered to source: 'gps' (IN_PERSON_SOURCE, backend/src/achievements/feature-context.ts:12); the user's already-earned UserAchievement ids; buildFeatureCounts() (derived counts from real tables: posts, comments, votes, board favorites, poll votes, message likes, patch photos, submissions, feedback, purchases, album shares, qualified referrals, multi-stop trips, fully-collected trips); and the user's UserFeatureEvent rows (one-shot tokens like opened_compass).
  3. Evaluation. These are folded into a pure, synchronous EarnedContext object (patches + featureEvents + featureCounts, no DB/clock/IO) and handed to AchievementEvaluatorService.evaluate() (backend/src/achievements/achievement-evaluator.service.ts), which filters the achievement list down to qualifying ids by dispatching on family:
    • type — count of the user's patches whose collectionType matches achievement.placeType, compared against threshold.
    • volume — total patch count vs threshold.
    • geo — count of distinct patch.state values vs threshold.
    • appqualifiesApp(): splits achievement.metric on the last >=. A bare token (opened_compass) checks featureEvents.has(token); a key>=N shape checks featureCounts[key] >= N, failing closed (missing key reads as 0) on any typo.
    • behaviorevaluateBehavior() in secret-rules.ts, a switch over 4 wired slugs (patch-rush, groundhog-day, night-moves, early-bird; a 5th, photo-finish, is defined in the catalog but permanently false — it needs per-photo counts EarnedContext doesn't carry).
    • secret — looked up in SECRET_RULES (a Record<slug, predicate> keyed by achievement slug, not a generic engine); a slug with no entry never fires.
  4. Award. Newly-qualifying ids (excluding already-owned ones) are inserted into UserAchievement via createManyAndReturn({ skipDuplicates: true }) — chosen specifically so two concurrent award() calls (the read path's lazy seed and a sync push both fire on app open) each only get credit for the rows they actually inserted, not the full candidate list (backend/src/achievements/achievements.service.ts:102-118).
  5. XP and streak. payXp() sums xpValue (flat 10 per achievement, backend/scripts/seed-achievements.ts:117) for whatever was newly earned this call, and — independently — advances a daily streak if the user had any patch activity this call (progression.service.ts's advanceStreak/levelForXp). Both are written inside a SELECT ... FOR UPDATE transaction against user_progression to serialize concurrent callers and avoid lost updates.
  6. Response. The awarding endpoint returns newlyEarned (only when announce: true) as an array of EarnedAchievementDto (id, name, description, artUrl, deepLink — a deliberately narrow projection, not the raw row). The mobile client enqueues these into useAppStore().achievementQueue, which feeds AchievementDeck.
  7. Retroactive-seed suppression. The very first time award() runs for a long-history user with existing patches but zero UserAchievement rows (detected via hasAchievement/hasPatch checks in both SyncService.awardAchievements and FeatureEventsController.awardForEvent), it passes announce: false. The rows are still written and XP is still paid, but nothing is reported as "newly earned" — this avoids dumping dozens of unlock cards on one user in one shot. AchievementsController.list() has its own equivalent lazy-seed path (seedIfNeverEvaluated, always announce: false) so a user who opens /achievements before their next sync still sees correct earned flags.
  8. Read path. GET /api/achievements lazily seeds if never evaluated, then returns every isActive && isApproved achievement (unearned ones included in full — no redaction) plus the user's UserProgression (xp, level, currentStreak, longestStreak, defaulting to 0/1/0/0 if no row exists yet).

Data model (Prisma models and key fields)

backend/prisma/schema.prisma:1766-1871

API surface

All four endpoints require JwtAuthGuard (signed-in users only); the admin ones additionally require AdminGuard / ContentWriteGuard.

Key files

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Backend (backend/src/achievements/__tests__/ unless noted):

Mobile:

Open questions