Achievements module
Configurable, event-driven gamification. An admin defines badges and the conditions
that hand them out; the engine watches the platform's domain-event bus and awards a user
when their conditions are met ; integrated with every core + extended module, with no
per-module wiring. Package API + data model: packages/achievements/README.md.
Why it "integrates with everything"
The app has a live event-type registry ; every module calls registerEventTypes(...)
at boot, declaring its event types and their payload fields. Achievements reuses that:
- A condition (
AchievementRule) stores aneventTypestring. The rule editor lists every registered type (listEventTypesByModule) and, for the chosen one, its payload fields (eventFieldsFor) : so the admin picks a trigger and its subject / match fields from real metadata. Add a new module with new events, and they show up automatically. - Events carry
actorId(the acting user) by convention.subjectFielddefaults to it, but can be any payload field ; including an array (assigneeIds) to credit several users.
There is no coupling to any specific module; achievements only knows "event types" and "payload fields", which is exactly what the registry exposes.
The durable engine (outbox + worker)
Awarding is durable (a restart must never drop a count), so it mirrors the automation run outbox rather than acting inline on the best-effort in-memory bus:
emit(event) ──▶ wildcard subscriber ──▶ AchievementOutbox (PENDING)
(cheap: "any enabled rule │
for this org+type?" → persist) ▼
achievementsTick (worker)
claim (leased, status-guarded)
├─ recipientsFor(rule, event) (pure matcher)
├─ filter to HUMAN users
├─ incrementProgress (upsert on ruleId+userId)
└─ count ≥ threshold →
awardIfAbsent (unique achievementId+userId)
→ emit achievements.awarded + notify
- Subscriber : does only cheap work (an existence check + an insert), so a slow evaluation never blocks the mutation that emitted the event. Wrapped so a hiccup can't roll back the emitter.
- Claim :
claimOutboxBatchmovesnextAttemptAtinto the future (a lease) with aupdateManyguarded onstatus = PENDING, so exactly one worker owns a row until it's marked DONE or the lease expires (crash recovery) ; no RUNNING status needed. Enqueue stampsnextAttemptAtfrom the app clock so the worker'snowcomparison uses one clock (avoids app-vs-DB skew). - Idempotency : progress is an upsert-increment; the award has a unique
(achievementId, userId), so re-processing or crossing the threshold repeatedly awards once and emitsachievements.awardedonce. That also makes it loop-safe: because the award event setsactorId = userId, a meta-achievement watchingachievements.awardedcredits the earner ("earn 5 achievements") without an infinite loop. - Retry : a failed row backs off and dead-letters to
FAILEDafterACHIEVEMENTS_MAX_ATTEMPTS.
The pure matcher (ruleEventTypeMatches / payloadMatches / subjectsOf / recipientsFor)
lives in contracts/matching.ts and is unit-tested in isolation; the worker and subscriber
just orchestrate DB + bus around it.
Big-5 + boundaries
Extended module depending only on core packages. RBAC (achievements.manage / view),
event (achievements.awarded), notifications (in-app, en+fr), webhooks (free via the event
type), feature flag (achievements.enabled, default off). Own schema fragment; cascade flows
through Achievement → Organization; userIds are org-member soft refs (no core FK).
Web
/admin/achievements ; CRUD achievements + their conditions (event-type / subject /
match-field pickers from the live registry). /achievements ; the user gallery (earned
badges, progress bars, locked catalog). Both flag-gated (server 404 + flag-gated nav
entries).