@dappermountain/payload-plugin-achievements 0.5.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +298 -239
  2. package/dist/admin/LucideIconField.d.ts +3 -2
  3. package/dist/admin/LucideIconField.d.ts.map +1 -1
  4. package/dist/admin/LucideIconField.js +2 -2
  5. package/dist/catalog.d.ts +3 -0
  6. package/dist/catalog.d.ts.map +1 -1
  7. package/dist/catalog.js +8 -0
  8. package/dist/collections/EventTypes/access/index.d.ts +1 -11
  9. package/dist/collections/EventTypes/access/index.d.ts.map +1 -1
  10. package/dist/collections/Grants/hooks/afterGrantMutation.d.ts.map +1 -1
  11. package/dist/collections/Grants/hooks/afterGrantMutation.js +15 -0
  12. package/dist/collections/Logs/hooks/afterLogMutation.d.ts +4 -0
  13. package/dist/collections/Logs/hooks/afterLogMutation.d.ts.map +1 -0
  14. package/dist/collections/Logs/hooks/afterLogMutation.js +47 -0
  15. package/dist/collections/Logs/hooks/index.d.ts.map +1 -1
  16. package/dist/collections/Logs/hooks/index.js +3 -0
  17. package/dist/collections/Logs/index.js +1 -1
  18. package/dist/collections/Metrics/access/index.d.ts +1 -11
  19. package/dist/collections/Metrics/access/index.d.ts.map +1 -1
  20. package/dist/collections/Reactions/access/index.d.ts +2 -0
  21. package/dist/collections/Reactions/access/index.d.ts.map +1 -0
  22. package/dist/collections/Reactions/access/index.js +2 -0
  23. package/dist/collections/Reactions/index.d.ts +3 -0
  24. package/dist/collections/Reactions/index.d.ts.map +1 -0
  25. package/dist/collections/Reactions/index.js +97 -0
  26. package/dist/collections/fields/ruleFields.d.ts.map +1 -1
  27. package/dist/collections/fields/ruleFields.js +19 -0
  28. package/dist/collections/fields/whenSibling.d.ts +7 -0
  29. package/dist/collections/fields/whenSibling.d.ts.map +1 -0
  30. package/dist/collections/fields/whenSibling.js +21 -0
  31. package/dist/collections/index.d.ts.map +1 -1
  32. package/dist/collections/index.js +15 -1
  33. package/dist/collections/slugs.d.ts.map +1 -1
  34. package/dist/collections/slugs.js +1 -0
  35. package/dist/defaults.d.ts +10 -1
  36. package/dist/defaults.d.ts.map +1 -1
  37. package/dist/defaults.js +34 -5
  38. package/dist/extensions/actions.d.ts +8 -0
  39. package/dist/extensions/actions.d.ts.map +1 -0
  40. package/dist/extensions/actions.js +26 -0
  41. package/dist/extensions/registry.d.ts +2 -0
  42. package/dist/extensions/registry.d.ts.map +1 -1
  43. package/dist/extensions/registry.js +19 -0
  44. package/dist/index.d.ts +4 -4
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -3
  47. package/dist/plugin.d.ts.map +1 -1
  48. package/dist/plugin.js +9 -3
  49. package/dist/services/achievement/eventTypeRequiresActor.d.ts +1 -0
  50. package/dist/services/achievement/eventTypeRequiresActor.d.ts.map +1 -1
  51. package/dist/services/achievement/eventTypeRequiresActor.js +2 -0
  52. package/dist/services/achievement/grantSideEffects.d.ts +11 -0
  53. package/dist/services/achievement/grantSideEffects.d.ts.map +1 -1
  54. package/dist/services/achievement/grantSideEffects.js +32 -0
  55. package/dist/services/achievement/index.d.ts +3 -1
  56. package/dist/services/achievement/index.d.ts.map +1 -1
  57. package/dist/services/achievement/index.js +2 -1
  58. package/dist/services/achievement/runReactions.d.ts +20 -0
  59. package/dist/services/achievement/runReactions.d.ts.map +1 -0
  60. package/dist/services/achievement/runReactions.js +86 -0
  61. package/dist/tsconfig.tsbuildinfo +1 -0
  62. package/dist/types.d.ts +47 -7
  63. package/dist/types.d.ts.map +1 -1
  64. package/package.json +10 -9
package/README.md CHANGED
@@ -1,171 +1,322 @@
1
1
  # `@dappermountain/payload-plugin-achievements`
2
2
 
3
- A Payload CMS plugin for a **rule-based achievements and ranking system** — tiers, badges, points, grants, review workflows, and an append-only activity log — configured in Admin.
3
+ Achievements, tiers, points, and an activity log for Payload CMS — all configured in Admin.
4
4
 
5
- Most gamification features end up as one-off collections and scattered hooks. This plugin gives you a reusable engine: define *what* can be earned and *how* it unlocks in Payload Admin, then record events from your application code. Current tier and progress are **derived** from rules and history, so you don’t maintain a separate progress table that drifts out of sync.
5
+ You describe what people can earn and what has to happen first. Your app records those happenings. The plugin derives the current tier and progress from that history, so the ladder stays in step with real activity.
6
6
 
7
- ## Why this plugin
7
+ ## What you get
8
+
9
+ - **A catalog in Admin.** Achievements, tiers, metrics, event types, and reactions. Editors change rules there.
10
+ - **Composable rules.** Nested all-of / any-of trees, plus your own leaf types.
11
+ - **Derived tiers.** Current tier and next-tier fill come from the same unlock rules.
12
+ - **Request and review.** Members can ask for an achievement; reviewers approve or reject. Composed achievements can grant themselves when their completion rules pass.
13
+ - **An activity log.** Grants, score changes, tier moves, and your own event kinds.
14
+ - **Optional multi-tenant scope.** Point `scope` at your tenants / workspaces / orgs collection.
15
+ - **Host hooks.** Prefixable slugs, collection overrides, a review policy you inject, and server helpers for trusted code.
16
+
17
+ ## Payload version support
8
18
 
9
- - **Admin-owned catalog** — Admins create achievements, tiers, metrics, and event types in Payload without a deploy.
10
- - **Composable rules** — Nested AND/OR trees with built-in leaf types (`tier-at-least`, `achievement-complete`, `metric-minimum`, `event-count`) plus host-defined rule types.
11
- - **Derived progress** — Ladder position and next-tier fill come from evaluating the same rules that unlock ranks. No fragile “progress” documents to keep in sync.
12
- - **Request → review → grant** — Users can request achievements; reviewers approve or reject; composed achievements can auto-grant when completion rules pass.
13
- - **Append-only history** — Logs capture grants, metric deltas, tier changes, and custom event kinds for audits and analytics.
14
- - **Multi-tenant ready** — Optional `scope` (tenant / workspace / org) on catalog rows and user progress.
15
- - **Host-friendly** — Prefixable collection slugs, Form Builder–style overrides, injectable review policy, and server helpers for recording events from hooks and jobs.
19
+ | Plugin line | Payload | Notes |
20
+ | ----------- | ------- | ----- |
21
+ | **`1.x`** (this package — e.g. `1.0.0-beta.1`) | **4** (`^4.0.0-beta.1` / `^4.0.0`) | Current line. Opt out of P4 default versions/authorship on high-churn collections. |
22
+ | **`0.6.x`** | **3** (`^3.89`) | Last Payload 3 line. Same product features (reactions, composites, etc.). Stay on `0.6.x` until you upgrade the host to Payload 4. |
16
23
 
17
- ## Requirements
24
+ One published version cannot peer both Payload majors (Admin field types and collection defaults diverge). Pick the line that matches your host.
18
25
 
19
- - Payload `^3.89`
20
- - `@payloadcms/ui` `^3.89`
21
- - `@payloadcms/richtext-lexical` `^3.89`
26
+ ## Requirements (1.x)
27
+
28
+ - Payload `^4.0.0-beta.1` / `^4.0.0`
29
+ - `@payloadcms/ui` (same train as Payload)
30
+ - `@payloadcms/richtext-lexical` (same train as Payload)
31
+ - `lucide-react` `^0.543`
22
32
  - React `^19`
23
33
 
24
- ## Install
34
+ Plugin collections opt out of Payload 4’s default versions and authorship (append-only logs and high-churn grants should not grow `_versions` tables).
25
35
 
26
- Preferred package manager: **Bun**.
36
+ ## Install
27
37
 
28
38
  ```bash
29
- bun add @dappermountain/payload-plugin-achievements
30
- ```
39
+ # Payload 4 hosts
40
+ bun add @dappermountain/payload-plugin-achievements@^1.0.0-beta.1
31
41
 
32
- Other clients also work (`npm install @dappermountain/payload-plugin-achievements`, etc.).
42
+ # Payload 3 hosts (stay on the 0.6 line)
43
+ bun add @dappermountain/payload-plugin-achievements@0.6
44
+ ```
33
45
 
34
- Next.js hosts should list the package in `transpilePackages` so `'use client'` admin components compile. Host imports stay on the package name; relative `.js` specifiers are internal to this package.
46
+ `npm` and other clients work too. Next.js hosts should list the package in `transpilePackages` so the `'use client'` Admin fields compile.
35
47
 
36
- ## Quick start
48
+ ## Wire it up
37
49
 
38
50
  ```ts
39
51
  import { buildConfig } from 'payload'
40
52
  import { achievementPlugin } from '@dappermountain/payload-plugin-achievements'
41
53
 
42
54
  export default buildConfig({
43
- // ...
44
55
  plugins: [
45
56
  achievementPlugin({
46
57
  usersCollectionSlug: 'users',
47
- canReview: (user, scopeId) => {
48
- // Return true when this user may edit catalogs and approve requests
49
- // for the given scope (or null for unscoped / global).
50
- return Boolean(user?.roles?.includes('admin'))
51
- },
52
- // Optional: scope catalog + progress to a tenant collection
58
+ canReview: (user, scopeId) => Boolean(user?.roles?.includes('admin')),
53
59
  // scope: { collection: 'tenants', relationField: 'scope' },
54
60
  }),
55
61
  ],
56
62
  })
57
63
  ```
58
64
 
59
- After installing, regenerate the Admin import map so conditional log fields resolve:
65
+ `canReview` is how staff get to edit catalogs and approve requests. It receives the user and a scope id (`null` when the row is global).
60
66
 
61
- ```bash
62
- payload generate:importmap
63
- ```
67
+ Then:
64
68
 
65
- Your app still owns schema and migrations — run `payload migrate` (or your usual migrate flow) as you would for any new collections.
69
+ 1. `payload generate:importmap` — Admin fields for logs, subjects, and Lucide icons.
70
+ 2. Migrate (or push) schema. The plugin adds collections; your app still owns the migrate runner.
71
+ 3. Restart so Payload can seed the system catalog on `onInit` (`points`, `metric.delta`, `achievement.granted`, `achievement.revoked`, `tier.changed`). Set `seedSystemCatalog: false` if you want to call `seedAchievementCatalog` yourself.
66
72
 
67
- Seed the built-in engine catalog (points metric + system event types) once:
73
+ Your product catalog — extra event types, metrics, tiers, achievements — is `seedAchievements`.
68
74
 
69
- ```ts
70
- import { seedAchievementCatalog } from '@dappermountain/payload-plugin-achievements'
75
+ ## How the pieces fit
71
76
 
72
- await seedAchievementCatalog(payload)
73
- ```
77
+ Think of four layers:
74
78
 
75
- ## What you get
79
+ 1. **Catalog** — named things in Admin (event types, metrics, tiers, achievements, reactions).
80
+ 2. **Log** — what happened. Your hooks call `recordLog` / `recordMetricChange`. Grant and revoke write their own rows.
81
+ 3. **Grants** — what someone has earned. One row per user (+ scope). Creating or deleting a grant updates the log and re-checks composed achievements and tiers.
82
+ 4. **Reactions** — set-based side effects when a host event is logged or an achievement is granted/revoked.
76
83
 
77
- ### Collections
84
+ Writing a host or metric log also re-checks composed achievements and derived tiers. Grant and revoke logs skip that path because the Grants hooks already ran it.
78
85
 
79
- Slugs are prefixed with `achievement-` by default so they don’t collide with your own collections.
86
+ ## Collections
87
+
88
+ Slugs use the prefix `achievement-` so they sit beside your own collections. Override with `collections.prefix` or per-key `collections.slugs`.
80
89
 
81
90
  | Config key | Default slug | Purpose |
82
91
  | --- | --- | --- |
83
- | `metrics` | `achievement-metrics` | Named numbers: **stored** (score totals) or **computed** (e.g. elapsed days). |
84
- | `metricBalances` | `achievement-metric-balances` | Projection of stored metric totals per user (+ scope) for fast reads / leaderboards. |
92
+ | `metrics` | `achievement-metrics` | Named numbers: **stored** totals or **computed** values (elapsed time). |
93
+ | `metricBalances` | `achievement-metric-balances` | Running totals for stored metrics, per user (+ scope). Leaderboards read this. |
85
94
  | `eventTypes` | `achievement-event-types` | Kinds of log entries you can count or require. |
86
- | `tiers` | `achievement-tiers` | Ordered ladder steps with unlock rules. |
87
- | `achievements` | `achievement-definitions` | Things a user can earn (with eligibility / completion rules). |
88
- | `grants` | `achievement-grants` | One row per earned achievement per user (+ scope). |
95
+ | `tiers` | `achievement-tiers` | Ladder steps with unlock rules. |
96
+ | `achievements` | `achievement-definitions` | Earnable items with eligibility and completion rules. |
97
+ | `grants` | `achievement-grants` | One earned achievement per user (+ scope). |
89
98
  | `achievementRequests` | `achievement-requests` | Request → pending / approved / rejected. |
90
- | `tierRequests` | `achievement-tier-requests` | Tier unlock awaiting review (when the tier has `requiresReview`). |
91
- | `logs` | `achievement-logs` | Member activity history (grants, revokes, tier moves, metrics, custom events). |
99
+ | `tierRequests` | `achievement-tier-requests` | Tier unlock waiting on review (`requiresReview` on the tier). |
100
+ | `logs` | `achievement-logs` | Activity history. |
101
+ | `reactions` | `achievement-reactions` | Side effects when something is logged or granted. |
102
+
103
+ System rows (`points` and the four engine event types) are protected. The plugin locks their slugs and blocks deletes.
104
+
105
+ ## Event types
106
+
107
+ Each log row points at an event type. Dotted slugs read well: `post.published`, `comment.created`, `course.completed`, `post.edited`.
92
108
 
93
- **System catalog rows** (seeded by `seedAchievementCatalog`) are protected from deletion and slug changes:
109
+ Flags on the type decide which log fields Admin asks for:
94
110
 
95
- | Slug | Role |
111
+ | Flag | Log field |
96
112
  | --- | --- |
97
- | `points` | Default metric |
98
- | `metric.delta` | Metric change log type (requires metric + change amount) |
99
- | `achievement.granted` | Written when a grant is created |
100
- | `achievement.revoked` | Written when a grant is deleted |
101
- | `tier.changed` | Written when derived tier moves (including demotions when a tier request leaves `approved`) |
113
+ | Requires an actor | `actor` — who caused it (a peer, an admin, …) |
114
+ | Adjusts a metric | `metric` + `change` |
115
+ | Requires a subject | `subject` — the host document (a post, a comment, a user, …) |
116
+ | Snapshot actor tiers | stamps the actor’s derived tier at write time |
117
+
118
+ **Actor tier** on an `event-count` rule then counts only logs whose snapshot already met that tier in this scope. Leave Actor tier blank to count every matching event. A filter only matches logs that were stamped.
119
+
120
+ Unscoped logs stamp every ladder. Scoped logs stamp that tenant plus any unscoped tiers.
121
+
122
+ ## Subjects
123
+
124
+ Logs can link to a host document the same way they link to an actor.
125
+
126
+ ```ts
127
+ achievementPlugin({
128
+ canReview,
129
+ subjects: {
130
+ collections: ['posts', 'comments', 'users'],
131
+ },
132
+ })
133
+ ```
134
+
135
+ That turns on:
136
+
137
+ - **Requires a subject** and **Subject collections** on event types
138
+ - a polymorphic **Subject** field on logs
139
+ - `recordLog({ subject: logSubject('posts', postId) })`
140
+
141
+ `collections: 'all'` includes every collection on your Payload config except this plugin’s slugs and anything prefixed `payload-`. It is optional. The picker and generated types grow with your schema, and collections you add later show up after a restart.
142
+
143
+ Example — “this member enrolled”, scoped to a tenant, subject = the user:
144
+
145
+ ```ts
146
+ await recordLog({
147
+ payload,
148
+ req,
149
+ userId: memberId,
150
+ scopeId: tenantId,
151
+ type: 'user.enrolled',
152
+ subject: logSubject('users', memberId),
153
+ })
154
+ ```
155
+
156
+ Same shape as `post.published` with `subject: logSubject('posts', postId)`.
157
+
158
+ Put host document links on `subject`. Engine grant/revoke/tier rows use a small `{ from, to }` envelope in `data`:
159
+
160
+ | type | `from` | `to` |
161
+ | --- | --- | --- |
162
+ | `tier.changed` | previous tier (`null` on first climb) | current tier |
163
+ | `achievement.granted` | `null` | achievement |
164
+ | `achievement.revoked` | achievement | `null` |
102
165
 
103
- Event types can require an **actor** and/or a **metric + change**. Admin only prompts for those fields when the type needs them. Optional **Snapshot actor tiers** stores the actor’s derived ladder rank on the log at write time (`resolveCurrentTier` only — no grants or tier requests). Unscoped logs stamp every ladder scope; scoped logs stamp that tenant plus any unscoped ranks. Use that with Event count → **Actor tier** to count only events whose actor held at least that step in the rule’s scope when the event was written. Leave Actor tier empty to count every matching event. Logs without a snapshot (legacy rows, or types that do not stamp) do not match an Actor tier filter.
166
+ ## Rules
104
167
 
105
- ### Rules engine
168
+ Unlock (tiers), eligibility, and completion (achievements) share one tree:
106
169
 
107
- Unlock (tiers), eligibility, and completion (achievements) share the same rule tree shape:
170
+ - Empty top-level list — everyone passes
171
+ - Groups — **All of these** (AND) or **Any of these** (OR)
172
+ - Built-in leaves — `tier-at-least`, `achievement-complete`, `metric-minimum`, `event-count`
173
+ - Your leaves — `extensions.ruleTypes` (`type`, `evaluate`, optional `progress`, `progressRole`, `label`, `fields`)
108
174
 
109
- - Top-level empty list → everyone passes
110
- - Nested groups → AND (“all of these”) or OR (“any of these”)
111
- - Built-in leaves → `tier-at-least`, `achievement-complete`, `metric-minimum`, `event-count`
112
- - Custom leaves → register via `extensions.ruleTypes`
175
+ `label` and `fields` show up in the Admin rule editor. The plugin only displays those fields when that leaf type is selected.
113
176
 
114
- **Current tier** is computed by walking tiers in `rank` order and evaluating each tier’s unlock rules (`resolveCurrentTier`). Tiers with **`requiresReview`** only become current after an **approved tier request**. Turning **`requiresReview` off** approves pending tier requests and writes missing `tier.changed` logs for members who already meet unlock rules (including those who never had a pending request). **Next-tier fill** uses fractional progress on the same trees (`resolveTierProgress` / `evaluateRuleProgress`):
177
+ **Eligibility** gates who may request an achievement. **Completion** describes what a composed achievement is made of. When `requiresReview` is off and completion passes, the parent grants itself. Nested achievement graphs skip cycles.
115
178
 
116
- | Combinator / leaf | Progress behavior |
179
+ **Current tier** walks the ladder in `rank` order (`resolveCurrentTier`). A tier with `requiresReview` becomes current after an **approved** tier request. Turning `requiresReview` off approves pending requests for that row and writes missing `tier.changed` logs for people who already meet the unlock rules.
180
+
181
+ **Next-tier fill** (`resolveTierProgress` / `evaluateRuleProgress`):
182
+
183
+ | Combinator / leaf | Progress |
117
184
  | --- | --- |
118
185
  | AND | Equal average of *requirement* children |
119
- | OR | Best child (max) |
186
+ | OR | Best child |
120
187
  | `tier-at-least` | Gate — skipped in AND averages |
121
- | `event-count` / `metric-minimum` | Fraction of the target. `event-count` may set **Actor tier** (at-least, this scope) against the log’s actor-tier snapshot |
122
- | `achievement-complete` | `1` if granted; otherwise rolls up that achievement’s completion (or eligibility) rules |
188
+ | `event-count` / `metric-minimum` | Fraction of the target |
189
+ | `achievement-complete` | `1` if granted; otherwise that achievement’s own completion (or eligibility) tree |
190
+
191
+ `buildUnlockRequirementLeaves` walks a tree and returns met/progress plus catalog subjects (`relations`, `target`, `unit`, `since`). Your UI supplies the copy. `achievement-complete` is omitted by default so those stay on catalog groups. `eachRuleLeaf` is the walker on its own.
192
+
193
+ ## Metrics
194
+
195
+ **Stored** metrics (default `points`) are sums of `change` on `metric.delta` logs. `recordMetricChange` writes the log and updates `achievement-metric-balances` together. Reconcile rebuilds balances from the log if they drift.
196
+
197
+ **Computed** metrics (v1: elapsed time) are derived at evaluation:
198
+
199
+ - `kind: 'computed'`, `compute: 'elapsed'`
200
+ - `unit`: seconds, minutes, hours, days, years (365-day years)
201
+ - `since`: `user-created-at`, `first-event` (plus an event type), or a key from `extensions.metricAnchors`
202
+
203
+ System seed creates stored `points`. You seed computed metrics after your anchors are registered.
204
+
205
+ **Leaderboards** (`getMetricLeaderboard` / `GET /api/achievements/leaderboard?metric=points`) rank stored balances for authenticated users. Each row is `{ user, value, rank }`. Your UI adds names, current tier, and who may appear. For a single user’s computed value, call `resolveMetricValue`.
123
206
 
124
- **Computed elapsed metrics** — `kind: computed` + `compute: elapsed` + `unit` (`seconds` | `minutes` | `hours` | `days` | `years`) + `since` (built-in `user-created-at` / `first-event`, or a host `metricAnchors` key). Gate tenure with `metric-minimum` against that metric. Years use a fixed 365-day length. System seed only creates stored `points` — product computed metrics (e.g. `days`) are host-seeded after registering anchors.
207
+ ## Reactions
125
208
 
126
- **Stored metrics** — totals live in `achievement-metric-balances`, updated by `recordMetricChange` (log + balance dual-write). Logs remain the audit/rebuild source; reconcile backfills balances.
209
+ A reaction is: **when this happens, run this command**.
127
210
 
128
- **Leaderboards** — `getMetricLeaderboard` / `GET /api/achievements/leaderboard?metric=points` ranks **stored** metric balances (authenticated, optional `scope`). Computed/elapsed metrics (e.g. tenure days) have no balance rows and are rejected — resolve a single user with `resolveMetricValue`, or materialize snapshots if you need a ranked computed board. Disable with `endpoints.leaderboard: false`. Hosts compose member directories (names, current tier, earned counts, who may appear) themselves; the plugin returns `{ user, value, rank }` only.
211
+ Triggers:
129
212
 
130
- **Unlock checklist** — `buildUnlockRequirementLeaves` walks a rule tree (including Payload leaves that carry empty `rules: []`), evaluates met/progress per leaf, and returns catalog subjects (`relations`, `target`, `unit`, `since`). It does **not** build UI copy — hosts format labels. `achievement-complete` is omitted by default so those stay on catalog groups. `eachRuleLeaf` is also exported if you need the walker alone.
213
+ - **Event logged** — a host event type (for example `course.completed`)
214
+ - **Achievement granted** / **revoked** — a specific achievement
131
215
 
132
- **Review snapshot** — `getUserProgress({ include: { reviews: true } })` or `loadUserProgressReviews` adds pending achievement keys and open/rejected tier requests so hosts do not re-query the engine for badge state.
216
+ Built-in action: **Grant achievement** (no-op if they already have it).
217
+
218
+ Your actions go in `extensions.actions`. Each one needs a `type` (a command slug like `accessGroup.addMember`), optional `label` / `fields` for Admin, and a `run` function. `run` should be set-based: adding someone who is already a member is a no-op. Reactions run in the same request as the log or grant. Start a job from `run` if the work belongs on a queue.
219
+
220
+ Grant/revoke reactions fire from the grant row. Point event reactions at your host event types (`post.published`, `course.completed`, …).
221
+
222
+ ## Custom rules, anchors, and actions
223
+
224
+ ```ts
225
+ achievementPlugin({
226
+ canReview,
227
+ extensions: {
228
+ metricAnchors: {
229
+ 'membership-started': {
230
+ label: ({ t }) => t('custom:achievements:anchors:membershipStarted'),
231
+ resolve: async ({ payload, userId, scopeId }) => {
232
+ if (!scopeId) return null
233
+ const found = await payload.find({
234
+ collection: 'memberships',
235
+ limit: 1,
236
+ overrideAccess: true,
237
+ where: {
238
+ and: [
239
+ { user: { equals: userId } },
240
+ { workspace: { equals: scopeId } },
241
+ ],
242
+ },
243
+ })
244
+ const startedAt = (found.docs[0] as { startedAt?: string } | undefined)?.startedAt
245
+ return startedAt ? new Date(startedAt) : null
246
+ },
247
+ },
248
+ },
249
+ ruleTypes: [
250
+ {
251
+ type: 'host.course-complete',
252
+ label: 'Course complete',
253
+ fields: [
254
+ { name: 'course', type: 'relationship', relationTo: 'courses', required: true },
255
+ ],
256
+ evaluate: async ({ rule, userId, payload, req }) => {
257
+ // return true when this user has completed rule.course
258
+ return true
259
+ },
260
+ },
261
+ ],
262
+ actions: [
263
+ {
264
+ type: 'accessGroup.addMember',
265
+ label: 'Add to access group',
266
+ fields: [
267
+ {
268
+ name: 'accessGroup',
269
+ type: 'relationship',
270
+ relationTo: 'access-groups',
271
+ required: true,
272
+ },
273
+ ],
274
+ run: async ({ userId, reaction, req }) => {
275
+ // merge the user into the group; already-a-member is a no-op
276
+ },
277
+ },
278
+ ],
279
+ },
280
+ })
281
+ ```
133
282
 
134
- **System catalog** — by default the plugin runs `seedAchievementCatalog` on Payload `onInit` (opt out with `seedSystemCatalog: false`). Hosts still seed product catalog + locales via `seedAchievements`.
283
+ Anchor `label` is a Payload Admin string (`OptionLabel`): plain text, `{ en, es }`, or `({ t }) => t('custom:…')`. Your app owns those `custom:` keys.
135
284
 
136
- **Eligibility rules** gate who may request an achievement. **Completion rules** define what a composed achievement is made of. When `requiresReview` is false and completion rules pass, the parent can auto-grant. Cycles in nested achievement graphs are ignored.
285
+ Then seed a computed metric (`since: 'membership-started'`) and use `metric-minimum` in rules.
137
286
 
138
- ### Access model
287
+ ## Access
139
288
 
140
- - **`canReview`** — Required for privileged writes (catalogs, grants, logs, approving requests). If omitted, those writes are denied.
141
- - User requests still go through normal collection access and eligibility hooks.
142
- - Server helpers (`recordLog`, `recordMetricChange`, `grantAchievement`, `resolveCurrentTier`, …) run with `overrideAccess: true`. Call them only from trusted server code (hooks, jobs, locked-down endpoints).
289
+ - Catalog, grants, logs, reactions, and request approval use `canReview(user, scopeId)`.
290
+ - Metric balances: reviewers can read and delete; trusted helpers write them.
291
+ - Member-facing requests use normal collection access plus eligibility hooks.
292
+ - Server helpers (`recordLog`, `recordMetricChange`, `grantAchievement`, …) run with `overrideAccess: true`. Call them from hooks, jobs, and locked-down endpoints. Pass `req` so nested Local API stays in the same transaction.
143
293
 
144
- ### Localization
294
+ ## Localization
145
295
 
146
- When your Payload config enables **`localization`**, catalog display fields are already `localized: true`:
296
+ With Payload `localization` enabled, these catalog fields are already `localized: true`:
147
297
 
148
- | Collection | Localized | Not localized |
298
+ | Collection | Localized | Other |
149
299
  | --- | --- | --- |
150
- | Achievements / tiers | `name` (text), `description` (**Lexical** rich text) | `slug`, rules, flags |
151
- | Metrics / event types | `name` | `slug`, system flags |
300
+ | Achievements / tiers | `name` (text), `description` (Lexical) | `slug`, rules, flags |
301
+ | Metrics / event types | `name` | `slug`, flags |
152
302
 
153
- Tier `icon` is a searchable Lucide picker in Admin (stores the kebab-case name, e.g. `sparkles`). Hosts must peer-depend on `@payloadcms/richtext-lexical` and `lucide-react` (same major as Payload). After upgrading, run `payload generate:importmap` and push/migrate schema so `description` columns accept JSON.
303
+ Tier `icon` is a Lucide picker (stores `sparkles`, `shield`, …). Peer-depend on `@payloadcms/richtext-lexical` and `lucide-react`. After upgrading, regenerate the import map and migrate so `description` columns accept JSON.
154
304
 
155
- Progress / `GET /api/achievements/me` pass through catalog `description` as **Lexical** (same as the collection document). Convert to HTML/plaintext in the UI with `@payloadcms/richtext-lexical` helpers (`convertLexicalToHTML`, `convertLexicalToPlaintext`) — the plugin also exports `descriptionToPlaintext` / `plainTextToLexical` for hosts.
305
+ `GET /api/achievements/me` and progress helpers return Lexical `description` as stored. Convert in your UI (`convertLexicalToHTML`, `convertLexicalToPlaintext`, or the plugin’s `descriptionToPlaintext` / `plainTextToLexical`).
156
306
 
157
- UI chrome and validation messages ship in English. Application UI copy stays in your app’s i18n layer — the plugin does not inject host translation dictionaries. Seed extra locales yourself after the English system catalog is in place.
307
+ Admin chrome ships in English. Member-facing copy lives in your i18n bundle. Seed extra locales after the English system catalog exists.
158
308
 
159
- ## Configuration reference
309
+ ## Configuration
160
310
 
161
311
  ```ts
162
312
  achievementPlugin({
163
313
  enabled?: boolean
314
+ seedSystemCatalog?: boolean
164
315
  usersCollectionSlug?: string
165
316
  canReview?: (user, scopeId) => boolean | Promise<boolean>
166
317
  scope?: { collection: string; relationField?: string }
167
318
  mediaCollection?: string
168
- slugPrefix?: string // deprecated — prefer collections.prefix
319
+ slugPrefix?: string // deprecated; use collections.prefix
169
320
  collections?: {
170
321
  prefix?: string
171
322
  adminGroup?: string | false
@@ -174,35 +325,37 @@ achievementPlugin({
174
325
  }
175
326
  users?: { includeJoins?: boolean }
176
327
  endpoints?: { me?: string | false; leaderboard?: string | false; reconcile?: string | false }
328
+ subjects?: { collections?: 'all' | string[] }
177
329
  extensions?: {
178
330
  ruleTypes?: AchievementRuleType[]
179
- metricAnchors?: Record<string, AchievementMetricAnchor>
331
+ metricAnchors?: Record<string, AchievementMetricAnchorEntry>
332
+ actions?: AchievementAction[]
180
333
  }
181
334
  })
182
335
  ```
183
336
 
184
- ### Core options
337
+ ### Core
185
338
 
186
339
  | Option | Default | Notes |
187
340
  | --- | --- | --- |
188
- | `enabled` | `true` | Set `false` to leave the plugin installed but inactive (no collections / endpoints merged). |
189
- | `seedSystemCatalog` | `true` | On `onInit`, upsert system metrics + event types (`points`, `metric.delta`, …). |
190
- | `usersCollectionSlug` | `'users'` | Users collection the plugin relates to (grants, requests, joins). |
191
- | `canReview` | _(denied)_ | **Important.** Without this, users cannot manage catalogs or approve requests. Receives `(user, scopeId)` where `scopeId` may be `null`. |
192
- | `scope` | _(none)_ | Multi-tenant hook-up. `collection` is the relation target (e.g. `'tenants'`). `relationField` defaults to `'scope'`. Adds an optional scope field across plugin collections. |
193
- | `mediaCollection` | _(none)_ | When set (e.g. `'media'`), tiers get an optional upload field for ladder badge images. Icon string keys still work without this. |
194
- | `extensions.metricAnchors` | _(none)_ | Named date resolvers for computed elapsed metrics. Prefer `{ label, resolve }` where `label` is a Payload Admin {@link OptionLabel} (string, `{ en, es }`, or `({ t }) => t('custom:…')`). Not CMS localization. |
341
+ | `enabled` | `true` | `false` leaves the package installed with no collections or endpoints. |
342
+ | `seedSystemCatalog` | `true` | Upserts system metrics and event types on `onInit`. |
343
+ | `usersCollectionSlug` | `'users'` | Users collection for grants, requests, and joins. |
344
+ | `canReview` | denied | Staff catalog and approval access. `scopeId` may be `null`. |
345
+ | `scope` | unset | `collection` is the tenant target. `relationField` defaults to `'scope'`. |
346
+ | `mediaCollection` | unset | e.g. `'media'` — optional upload on tiers for badge images. Icon keys still work. |
347
+ | `extensions.metricAnchors` | unset | Named dates for elapsed metrics. |
348
+ | `extensions.ruleTypes` | unset | Custom rule leaves; `label` / `fields` appear in Admin. |
349
+ | `extensions.actions` | unset | Extra reaction commands. Built-in `achievement.grant` is always there. |
195
350
 
196
351
  ### `collections`
197
352
 
198
353
  | Option | Default | Notes |
199
354
  | --- | --- | --- |
200
- | `prefix` | `'achievement'` | Prefix for default slugs → REST paths and DB table names (`achievement-tiers`, …). |
201
- | `adminGroup` | `'Achievements'` | Admin sidebar group. Pass `false` to leave collections ungrouped. |
202
- | `slugs` | _(derived)_ | Absolute slug overrides per collection key (ignores `prefix` for that key). |
203
- | `overrides` | _(none)_ | Per-collection patches: `access`, `admin`, `hooks`, `labels`. Access keys replace; hook arrays **append after** plugin hooks; `admin` / `labels` merge shallowly. |
204
-
205
- Example override (notify after a grant is created via request approval):
355
+ | `prefix` | `'achievement'` | Default slugs, REST paths, and table names. |
356
+ | `adminGroup` | `'Achievements'` | Sidebar group. `false` leaves collections ungrouped. |
357
+ | `slugs` | derived | Absolute slug per key (ignores `prefix` for that key). |
358
+ | `overrides` | unset | `access`, `admin`, `hooks`, `labels`. Access keys replace; hook arrays append after plugin hooks; `admin` / `labels` merge shallowly. |
206
359
 
207
360
  ```ts
208
361
  achievementPlugin({
@@ -229,98 +382,27 @@ achievementPlugin({
229
382
 
230
383
  | Option | Default | Notes |
231
384
  | --- | --- | --- |
232
- | `includeJoins` | `true` | Injects an `achievements` group on the users collection with joins to grants and requests. Set `false` if you own that UI yourself. |
385
+ | `includeJoins` | `true` | Adds an `achievements` group on users (`grants` / `requests` joins). `false` if you already own that UI. |
233
386
 
234
387
  ### `subjects`
235
388
 
236
- Host documents that logs may point at via a polymorphic `subject` field (same idea as `requiresActor` / `requiresMetric`).
237
-
238
389
  | Option | Default | Notes |
239
390
  | --- | --- | --- |
240
- | `collections` | `[]` | Allowlist of collection slugs (e.g. `posts`, `comments`). Empty / omit → subject feature is off (no log field, no event-type subject flags). |
241
-
242
- When configured:
243
-
244
- - Event types gain **Requires a subject** + optional **Subject collections** (`subjectRelationTo`).
245
- - Logs gain a polymorphic **Subject** relationship (Admin links to the host doc).
246
- - `recordLog({ subject: { relationTo, value } })` (or `logSubject(relationTo, value)`) writes the ref.
247
- - Prefer `subject` over stuffing ids into `data` JSON.
248
-
249
- ```ts
250
- achievementPlugin({
251
- canReview,
252
- subjects: {
253
- collections: ['posts', 'comments', 'endorsements'],
254
- },
255
- })
256
-
257
- await seedAchievements(payload, {
258
- eventTypes: [
259
- {
260
- name: 'Comment created',
261
- slug: 'comment.created',
262
- requiresSubject: true,
263
- subjectRelationTo: ['comments'],
264
- },
265
- ],
266
- })
267
-
268
- await recordLog({
269
- payload,
270
- userId,
271
- scopeId,
272
- type: 'comment.created',
273
- actorId: userId,
274
- subject: logSubject('comments', commentId),
275
- })
276
- ```
391
+ | `collections` | `[]` | Host slugs for log `subject`, or `'all'`. Empty skips the feature. |
277
392
 
278
393
  ### `endpoints`
279
394
 
280
395
  | Option | Default | Notes |
281
396
  | --- | --- | --- |
282
- | `me` | `'/achievements/me'` | Registers `GET /api/achievements/me` for the signed-in user’s grants snapshot (+ derived tiers and lean requests). Pass `false` to skip. Having review access does **not** widen this endpoint to other users. Optional `?reviews=1` adds `reviews`. |
283
- | `leaderboard` | `'/achievements/leaderboard'` | Registers `GET /api/achievements/leaderboard?metric=<slug>` for authenticated users (**stored** metrics only). Pass `false` to skip. |
284
- | `reconcile` | `'/achievements/reconcile'` | Registers `POST /api/achievements/reconcile` for reviewers. Pass `false` to skip. |
397
+ | `me` | `'/achievements/me'` | Signed-in user’s grants, derived tiers, lean requests. `?reviews=1` adds a review snapshot. Always the caller’s own snapshot. `false` skips it. |
398
+ | `leaderboard` | `'/achievements/leaderboard'` | Stored-metric leaderboard. `false` skips it. |
399
+ | `reconcile` | `'/achievements/reconcile'` | Reviewer repair endpoint. `false` skips it. |
285
400
 
286
- Query params (`me`): `scope`, `limit`, `page`, `requestsLimit`, `reviews`. Leaderboard: `metric` (required), `scope`, `limit`, `page`.
287
-
288
- ### `extensions.ruleTypes` / `metricAnchors`
289
-
290
- Register custom rule evaluators and/or date anchors:
291
-
292
- ```ts
293
- achievementPlugin({
294
- canReview,
295
- extensions: {
296
- metricAnchors: {
297
- 'enrollment-started': {
298
- // Admin UI i18n (Payload label / OptionLabel)
299
- label: ({ t }) => t('custom:achievements:anchors:enrollmentStarted'),
300
- resolve: async ({ payload, userId, scopeId }) => {
301
- // return a Date from host data, or null
302
- return null
303
- },
304
- },
305
- },
306
- ruleTypes: [
307
- {
308
- type: 'host.custom-check',
309
- evaluate: async ({ rule, userId, payload, req }) => {
310
- // return true when the rule passes
311
- return true
312
- },
313
- // optional: progress: async () => 0.5,
314
- // optional: progressRole: 'requirement' | 'gate',
315
- },
316
- ],
317
- },
318
- })
319
- ```
401
+ `me` query: `scope`, `limit`, `page`, `requestsLimit`, `reviews`. Leaderboard: `metric` (required), `scope`, `limit`, `page`.
320
402
 
321
403
  ## Server helpers
322
404
 
323
- Use these from hooks, jobs, and trusted endpoints:
405
+ Call these from hooks, jobs, and trusted endpoints:
324
406
 
325
407
  ```ts
326
408
  import {
@@ -341,71 +423,50 @@ import {
341
423
  logSubject,
342
424
  } from '@dappermountain/payload-plugin-achievements'
343
425
 
344
- await recordLog({ payload, userId, scopeId, type: 'host.custom-kind', actorId })
345
- await recordLog({
346
- payload,
347
- userId,
348
- scopeId,
349
- type: 'comment.created',
350
- actorId: userId,
351
- subject: logSubject('comments', commentId),
352
- })
353
- await recordMetricChange({ payload, userId, scopeId, metric: 'points', change: 10 })
354
- const points = await resolveMetricValue({ payload, userId, scopeId, metric: 'points' })
355
- const board = await getMetricLeaderboard({ payload, metric: 'points', scopeId, limit: 20 })
356
- const tier = await resolveCurrentTier({ payload, userId, scopeId })
357
- const progress = await getUserProgress({ payload, userId, scopeId, include: { reviews: true } })
426
+ await recordLog({ payload, req, userId, scopeId, type: 'post.published', subject: logSubject('posts', postId) })
427
+ await recordMetricChange({ payload, req, userId, scopeId, metric: 'points', change: 10 })
428
+ const points = await resolveMetricValue({ payload, req, userId, scopeId, metric: 'points' })
429
+ const board = await getMetricLeaderboard({ payload, req, metric: 'points', scopeId, limit: 20 })
430
+ const tier = await resolveCurrentTier({ payload, req, userId, scopeId })
431
+ const progress = await getUserProgress({ payload, req, userId, scopeId, include: { reviews: true } })
358
432
  const unlockLeaves = await buildUnlockRequirementLeaves({
359
433
  payload,
434
+ req,
360
435
  userId,
361
436
  scopeId,
362
437
  rules: nextTier.unlockRules,
363
438
  ladderRank: tier?.rank ?? null,
364
439
  })
365
440
 
366
- // Repair users whose grants predate side-effect hooks (idempotent)
367
- await reconcileProgression({ payload })
368
-
369
- // Optional filters (ids or slugs)
441
+ await reconcileProgression({ payload, req })
370
442
  await reconcileProgression({
371
443
  payload,
444
+ req,
372
445
  userId: '…',
373
446
  scopeId: '…',
374
- achievementSlug: 'path-complete',
447
+ achievementSlug: 'first-steps',
375
448
  tierSlug: 'veteran',
376
449
  })
377
450
 
378
- // Host CLI (only boot config in the app — flags live in the plugin):
379
- // bun --env-file=.env ./scripts/reconcile-achievements.ts -- --user=<id> --achievement=path-complete
380
451
  import { runReconcileCli } from '@dappermountain/payload-plugin-achievements/cli'
381
452
  await runReconcileCli({ config })
382
453
  ```
383
454
 
384
- Pass catalog **slugs** (or ids) for `type` and `metric`. Metric totals are the sum of `change` on matching logs. Creating or deleting **grants** (Admin or API) writes `achievement.granted` / `achievement.revoked`, syncs composed achievements, and may open tier requests / write `tier.changed`.
455
+ Pass catalog **slugs** or ids for `type` and `metric`. Creating or deleting a grant writes `achievement.granted` / `achievement.revoked`, syncs composed achievements, and may open tier requests or write `tier.changed`.
385
456
 
386
- Engine log `data` for those system events uses a shared transition envelope:
457
+ Turning **Requires review** off on a definition or tier approves that row’s pending requests (each waiting user goes through the existing request hooks).
387
458
 
388
- ```ts
389
- { from: { id, slug? } | null, to: { id, slug? } | null }
390
- ```
459
+ The Grants list in Admin includes **Repair progression**, which `POST`s the reconcile endpoint (`canReview`).
391
460
 
392
- | type | `from` | `to` |
393
- | --- | --- | --- |
394
- | `tier.changed` | previous tier (null on first climb) | current tier |
395
- | `achievement.granted` | `null` | achievement |
396
- | `achievement.revoked` | achievement | `null` |
397
-
398
- Host document links belong on polymorphic **`subject`** (when `subjects.collections` is set), not inside `data`.
399
-
400
- Turning **Requires review** off on a definition or tier **approves pending requests** for that row only (each waiting user goes through the existing request hooks).
461
+ Also exported: `collectionOf` / `slugOf` (resolved collection slugs), `syncProgressionFromLog`, `runReactions`.
401
462
 
402
- **Admin:** Grants list includes **Repair progression**, which `POST`s the reconcile endpoint (requires `canReview`).
463
+ ## REST
403
464
 
404
- ## REST surface
465
+ Paths follow your `collections.prefix` / `slugs`.
405
466
 
406
467
  | Goal | Request |
407
468
  | --- | --- |
408
- | Current user snapshot | `GET /api/achievements/me?scope=<scopeId>&limit=10&page=1` (`&reviews=1` for pending/rejected review keys) |
469
+ | Current user snapshot | `GET /api/achievements/me?scope=<scopeId>&limit=10&page=1` (`&reviews=1` for review keys) |
409
470
  | Stored metric leaderboard | `GET /api/achievements/leaderboard?metric=points&scope=<scopeId>&limit=20&page=1` |
410
471
  | Repair progression | `POST /api/achievements/reconcile` (reviewer; optional `{ userId, scopeId, achievementId\|achievementSlug, tierId\|tierSlug, limit }`) |
411
472
  | List grants | `GET /api/achievement-grants?where[user][equals]=<userId>` |
@@ -413,9 +474,7 @@ Turning **Requires review** off on a definition or tier **approves pending reque
413
474
  | Request an achievement | `POST /api/achievement-requests` with `{ achievement }` |
414
475
  | Approve / reject achievement | `PATCH /api/achievement-requests/:id` with `{ status: "approved" \| "rejected" }` |
415
476
  | Approve / reject tier | `PATCH /api/achievement-tier-requests/:id` with `{ status: "approved" \| "rejected" }` |
416
- | Browse catalogs | `GET /api/achievement-event-types`, `GET /api/achievement-metrics`, … |
417
-
418
- Exact collection paths follow your `collections.prefix` / `slugs` settings.
477
+ | Browse catalogs | `GET /api/achievement-event-types`, `GET /api/achievement-metrics`, `GET /api/achievement-reactions`, … |
419
478
 
420
479
  ## Seeding
421
480
 
@@ -425,26 +484,27 @@ import {
425
484
  seedAchievements,
426
485
  } from '@dappermountain/payload-plugin-achievements'
427
486
 
428
- await seedAchievementCatalog(payload) // system metrics + event types (English)
487
+ await seedAchievementCatalog(payload)
429
488
 
430
489
  await seedAchievements(payload, {
431
490
  eventTypes: [
432
- { name: 'Custom kind', slug: 'host.custom-kind', requiresActor: true },
491
+ { name: 'Post published', slug: 'post.published', requiresSubject: true, subjectRelationTo: ['posts'] },
492
+ { name: 'Comment created', slug: 'comment.created', requiresActor: true, requiresSubject: true, subjectRelationTo: ['comments'] },
493
+ { name: 'User enrolled', slug: 'user.enrolled', requiresSubject: true, subjectRelationTo: ['users'] },
494
+ ],
495
+ metrics: [
496
+ { name: 'Streak days', slug: 'streak-days' },
433
497
  {
434
- name: 'Comment created',
435
- slug: 'comment.created',
436
- requiresSubject: true,
437
- subjectRelationTo: ['comments'],
498
+ name: 'Days as member',
499
+ slug: 'days-as-member',
500
+ kind: 'computed',
501
+ compute: 'elapsed',
502
+ unit: 'days',
503
+ since: 'membership-started',
438
504
  },
439
505
  ],
440
- metrics: [{ name: 'Streak days', slug: 'streak-days' }],
441
506
  tiers: [
442
- {
443
- name: 'Novice',
444
- slug: 'novice',
445
- rank: 0,
446
- unlockRules: [], // open
447
- },
507
+ { name: 'Novice', slug: 'novice', rank: 0, unlockRules: [] },
448
508
  ],
449
509
  achievements: [
450
510
  {
@@ -454,10 +514,11 @@ await seedAchievements(payload, {
454
514
  completionRules: [{ type: 'metric-minimum', metricSlug: 'points', minimum: 10 }],
455
515
  },
456
516
  ],
517
+ removeAchievementSlugs: ['old-slug'],
457
518
  })
458
519
  ```
459
520
 
460
- `name` / `description` accept a string (default locale) or a locale map (`{ en: '…', es: '…' }`) when Payload localization is enabled. Prefer seeding extra locales in the host after the English system catalog exists.
521
+ `name` / `description` accept a string (default locale) or `{ en: '…', es: '…' }` when localization is on. Seed extra locales after English system rows exist. Rule arrays in seed can use `metricSlug` / `eventTypeSlug` / `achievementSlug`; the seeder resolves them to relationships.
461
522
 
462
523
  ## License
463
524
 
@@ -465,12 +526,10 @@ MIT © Dapper Mountain
465
526
 
466
527
  ## Agent context (Cursor)
467
528
 
468
- This repo vendors AI agent rules and skills under [`.agents/`](.agents/). See [`AGENTS.md`](AGENTS.md).
529
+ Rules and skills live under [`.agents/`](.agents/). See [`AGENTS.md`](AGENTS.md).
469
530
 
470
531
  ```bash
471
532
  bun install
472
533
  bun run agents:sync
473
534
  bun run skills:install
474
535
  ```
475
-
476
- Dev dependencies: [`@dappermountain/agent-practices`](https://www.npmjs.com/package/@dappermountain/agent-practices), [`@dappermountain/agent-payload`](https://www.npmjs.com/package/@dappermountain/agent-payload). Run `agents:sync` after install or upgrade so rules land under `.agents/`.