@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.
- package/README.md +298 -239
- package/dist/admin/LucideIconField.d.ts +3 -2
- package/dist/admin/LucideIconField.d.ts.map +1 -1
- package/dist/admin/LucideIconField.js +2 -2
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +8 -0
- package/dist/collections/EventTypes/access/index.d.ts +1 -11
- package/dist/collections/EventTypes/access/index.d.ts.map +1 -1
- package/dist/collections/Grants/hooks/afterGrantMutation.d.ts.map +1 -1
- package/dist/collections/Grants/hooks/afterGrantMutation.js +15 -0
- package/dist/collections/Logs/hooks/afterLogMutation.d.ts +4 -0
- package/dist/collections/Logs/hooks/afterLogMutation.d.ts.map +1 -0
- package/dist/collections/Logs/hooks/afterLogMutation.js +47 -0
- package/dist/collections/Logs/hooks/index.d.ts.map +1 -1
- package/dist/collections/Logs/hooks/index.js +3 -0
- package/dist/collections/Logs/index.js +1 -1
- package/dist/collections/Metrics/access/index.d.ts +1 -11
- package/dist/collections/Metrics/access/index.d.ts.map +1 -1
- package/dist/collections/Reactions/access/index.d.ts +2 -0
- package/dist/collections/Reactions/access/index.d.ts.map +1 -0
- package/dist/collections/Reactions/access/index.js +2 -0
- package/dist/collections/Reactions/index.d.ts +3 -0
- package/dist/collections/Reactions/index.d.ts.map +1 -0
- package/dist/collections/Reactions/index.js +97 -0
- package/dist/collections/fields/ruleFields.d.ts.map +1 -1
- package/dist/collections/fields/ruleFields.js +19 -0
- package/dist/collections/fields/whenSibling.d.ts +7 -0
- package/dist/collections/fields/whenSibling.d.ts.map +1 -0
- package/dist/collections/fields/whenSibling.js +21 -0
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/collections/index.js +15 -1
- package/dist/collections/slugs.d.ts.map +1 -1
- package/dist/collections/slugs.js +1 -0
- package/dist/defaults.d.ts +10 -1
- package/dist/defaults.d.ts.map +1 -1
- package/dist/defaults.js +34 -5
- package/dist/extensions/actions.d.ts +8 -0
- package/dist/extensions/actions.d.ts.map +1 -0
- package/dist/extensions/actions.js +26 -0
- package/dist/extensions/registry.d.ts +2 -0
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +19 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +9 -3
- package/dist/services/achievement/eventTypeRequiresActor.d.ts +1 -0
- package/dist/services/achievement/eventTypeRequiresActor.d.ts.map +1 -1
- package/dist/services/achievement/eventTypeRequiresActor.js +2 -0
- package/dist/services/achievement/grantSideEffects.d.ts +11 -0
- package/dist/services/achievement/grantSideEffects.d.ts.map +1 -1
- package/dist/services/achievement/grantSideEffects.js +32 -0
- package/dist/services/achievement/index.d.ts +3 -1
- package/dist/services/achievement/index.d.ts.map +1 -1
- package/dist/services/achievement/index.js +2 -1
- package/dist/services/achievement/runReactions.d.ts +20 -0
- package/dist/services/achievement/runReactions.d.ts.map +1 -0
- package/dist/services/achievement/runReactions.js +86 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +47 -7
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -9
package/README.md
CHANGED
|
@@ -1,171 +1,322 @@
|
|
|
1
1
|
# `@dappermountain/payload-plugin-achievements`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Achievements, tiers, points, and an activity log for Payload CMS — all configured in Admin.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
## Install
|
|
27
37
|
|
|
28
38
|
```bash
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
# Payload 4 hosts
|
|
40
|
+
bun add @dappermountain/payload-plugin-achievements@^1.0.0-beta.1
|
|
31
41
|
|
|
32
|
-
|
|
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'`
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
payload generate:importmap
|
|
63
|
-
```
|
|
67
|
+
Then:
|
|
64
68
|
|
|
65
|
-
|
|
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
|
-
|
|
73
|
+
Your product catalog — extra event types, metrics, tiers, achievements — is `seedAchievements`.
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
import { seedAchievementCatalog } from '@dappermountain/payload-plugin-achievements'
|
|
75
|
+
## How the pieces fit
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
```
|
|
77
|
+
Think of four layers:
|
|
74
78
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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**
|
|
84
|
-
| `metricBalances` | `achievement-metric-balances` |
|
|
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` |
|
|
87
|
-
| `achievements` | `achievement-definitions` |
|
|
88
|
-
| `grants` | `achievement-grants` | One
|
|
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
|
|
91
|
-
| `logs` | `achievement-logs` |
|
|
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
|
-
|
|
109
|
+
Flags on the type decide which log fields Admin asks for:
|
|
94
110
|
|
|
95
|
-
|
|
|
111
|
+
| Flag | Log field |
|
|
96
112
|
| --- | --- |
|
|
97
|
-
| `
|
|
98
|
-
|
|
|
99
|
-
| `
|
|
100
|
-
|
|
|
101
|
-
|
|
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
|
-
|
|
166
|
+
## Rules
|
|
104
167
|
|
|
105
|
-
|
|
168
|
+
Unlock (tiers), eligibility, and completion (achievements) share one tree:
|
|
106
169
|
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
|
186
|
+
| OR | Best child |
|
|
120
187
|
| `tier-at-least` | Gate — skipped in AND averages |
|
|
121
|
-
| `event-count` / `metric-minimum` | Fraction of the target
|
|
122
|
-
| `achievement-complete` | `1` if granted; otherwise
|
|
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
|
-
|
|
207
|
+
## Reactions
|
|
125
208
|
|
|
126
|
-
|
|
209
|
+
A reaction is: **when this happens, run this command**.
|
|
127
210
|
|
|
128
|
-
|
|
211
|
+
Triggers:
|
|
129
212
|
|
|
130
|
-
**
|
|
213
|
+
- **Event logged** — a host event type (for example `course.completed`)
|
|
214
|
+
- **Achievement granted** / **revoked** — a specific achievement
|
|
131
215
|
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
+
Then seed a computed metric (`since: 'membership-started'`) and use `metric-minimum` in rules.
|
|
137
286
|
|
|
138
|
-
|
|
287
|
+
## Access
|
|
139
288
|
|
|
140
|
-
-
|
|
141
|
-
-
|
|
142
|
-
-
|
|
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
|
-
|
|
294
|
+
## Localization
|
|
145
295
|
|
|
146
|
-
|
|
296
|
+
With Payload `localization` enabled, these catalog fields are already `localized: true`:
|
|
147
297
|
|
|
148
|
-
| Collection | Localized |
|
|
298
|
+
| Collection | Localized | Other |
|
|
149
299
|
| --- | --- | --- |
|
|
150
|
-
| Achievements / tiers | `name` (text), `description` (
|
|
151
|
-
| Metrics / event types | `name` | `slug`,
|
|
300
|
+
| Achievements / tiers | `name` (text), `description` (Lexical) | `slug`, rules, flags |
|
|
301
|
+
| Metrics / event types | `name` | `slug`, flags |
|
|
152
302
|
|
|
153
|
-
Tier `icon` is a
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
331
|
+
metricAnchors?: Record<string, AchievementMetricAnchorEntry>
|
|
332
|
+
actions?: AchievementAction[]
|
|
180
333
|
}
|
|
181
334
|
})
|
|
182
335
|
```
|
|
183
336
|
|
|
184
|
-
### Core
|
|
337
|
+
### Core
|
|
185
338
|
|
|
186
339
|
| Option | Default | Notes |
|
|
187
340
|
| --- | --- | --- |
|
|
188
|
-
| `enabled` | `true` |
|
|
189
|
-
| `seedSystemCatalog` | `true` |
|
|
190
|
-
| `usersCollectionSlug` | `'users'` | Users collection
|
|
191
|
-
| `canReview` |
|
|
192
|
-
| `scope` |
|
|
193
|
-
| `mediaCollection` |
|
|
194
|
-
| `extensions.metricAnchors` |
|
|
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'` |
|
|
201
|
-
| `adminGroup` | `'Achievements'` |
|
|
202
|
-
| `slugs` |
|
|
203
|
-
| `overrides` |
|
|
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` |
|
|
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` | `[]` |
|
|
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'` |
|
|
283
|
-
| `leaderboard` | `'/achievements/leaderboard'` |
|
|
284
|
-
| `reconcile` | `'/achievements/reconcile'` |
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
345
|
-
await
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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: '
|
|
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**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
463
|
+
## REST
|
|
403
464
|
|
|
404
|
-
|
|
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
|
|
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)
|
|
487
|
+
await seedAchievementCatalog(payload)
|
|
429
488
|
|
|
430
489
|
await seedAchievements(payload, {
|
|
431
490
|
eventTypes: [
|
|
432
|
-
{ name: '
|
|
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: '
|
|
435
|
-
slug: '
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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
|
-
|
|
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/`.
|