@dappermountain/payload-plugin-achievements 0.3.0 → 0.4.0

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 (58) hide show
  1. package/README.md +91 -12
  2. package/dist/admin/SubjectField.d.ts +14 -0
  3. package/dist/admin/SubjectField.d.ts.map +1 -0
  4. package/dist/admin/SubjectField.js +38 -0
  5. package/dist/admin/useEventTypeFlags.d.ts +2 -0
  6. package/dist/admin/useEventTypeFlags.d.ts.map +1 -1
  7. package/dist/admin/useEventTypeFlags.js +21 -3
  8. package/dist/cli/reconcile.d.ts +1 -1
  9. package/dist/cli/reconcile.d.ts.map +1 -1
  10. package/dist/collections/EventTypes/index.d.ts.map +1 -1
  11. package/dist/collections/EventTypes/index.js +30 -0
  12. package/dist/collections/Logs/fields/validators.d.ts +1 -0
  13. package/dist/collections/Logs/fields/validators.d.ts.map +1 -1
  14. package/dist/collections/Logs/fields/validators.js +37 -1
  15. package/dist/collections/Logs/hooks/ensureTypeRequirements.d.ts.map +1 -1
  16. package/dist/collections/Logs/hooks/ensureTypeRequirements.js +22 -13
  17. package/dist/collections/Logs/index.d.ts.map +1 -1
  18. package/dist/collections/Logs/index.js +34 -3
  19. package/dist/defaults.d.ts +5 -1
  20. package/dist/defaults.d.ts.map +1 -1
  21. package/dist/defaults.js +17 -1
  22. package/dist/endpoints/me.d.ts +2 -1
  23. package/dist/endpoints/me.d.ts.map +1 -1
  24. package/dist/endpoints/me.js +7 -1
  25. package/dist/exports/client.d.ts +1 -0
  26. package/dist/exports/client.d.ts.map +1 -1
  27. package/dist/exports/client.js +1 -0
  28. package/dist/index.d.ts +5 -4
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +3 -2
  31. package/dist/seed/index.d.ts +7 -0
  32. package/dist/seed/index.d.ts.map +1 -1
  33. package/dist/seed/index.js +4 -0
  34. package/dist/services/achievement/buildUnlockRequirementLeaves.d.ts +48 -0
  35. package/dist/services/achievement/buildUnlockRequirementLeaves.d.ts.map +1 -0
  36. package/dist/services/achievement/buildUnlockRequirementLeaves.js +192 -0
  37. package/dist/services/achievement/duration.js +1 -1
  38. package/dist/services/achievement/eventTypeRequiresActor.d.ts +17 -0
  39. package/dist/services/achievement/eventTypeRequiresActor.d.ts.map +1 -1
  40. package/dist/services/achievement/eventTypeRequiresActor.js +56 -5
  41. package/dist/services/achievement/getUserProgress.d.ts +7 -0
  42. package/dist/services/achievement/getUserProgress.d.ts.map +1 -1
  43. package/dist/services/achievement/getUserProgress.js +11 -1
  44. package/dist/services/achievement/index.d.ts +7 -0
  45. package/dist/services/achievement/index.d.ts.map +1 -1
  46. package/dist/services/achievement/index.js +3 -0
  47. package/dist/services/achievement/loadUserProgressReviews.d.ts +23 -0
  48. package/dist/services/achievement/loadUserProgressReviews.d.ts.map +1 -0
  49. package/dist/services/achievement/loadUserProgressReviews.js +79 -0
  50. package/dist/services/achievement/logSubject.d.ts +13 -0
  51. package/dist/services/achievement/logSubject.d.ts.map +1 -0
  52. package/dist/services/achievement/logSubject.js +20 -0
  53. package/dist/services/achievement/recordEvent.d.ts +6 -0
  54. package/dist/services/achievement/recordEvent.d.ts.map +1 -1
  55. package/dist/services/achievement/recordEvent.js +32 -4
  56. package/dist/types.d.ts +19 -3
  57. package/dist/types.d.ts.map +1 -1
  58. package/package.json +7 -7
package/README.md CHANGED
@@ -1,6 +1,6 @@
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, not hard-coded in your app.
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.
4
4
 
5
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.
6
6
 
@@ -16,8 +16,9 @@ Most gamification features end up as one-off collections and scattered hooks. Th
16
16
 
17
17
  ## Requirements
18
18
 
19
- - Payload `^3.88`
20
- - `@payloadcms/ui` `^3.88`
19
+ - Payload `^3.89`
20
+ - `@payloadcms/ui` `^3.89`
21
+ - `@payloadcms/richtext-lexical` `^3.89`
21
22
  - React `^19`
22
23
 
23
24
  ## Install
@@ -120,11 +121,15 @@ Unlock (tiers), eligibility, and completion (achievements) share the same rule t
120
121
  | `event-count` / `metric-minimum` | Fraction of the target |
121
122
  | `achievement-complete` | `1` if granted; otherwise rolls up that achievement’s completion (or eligibility) rules |
122
123
 
123
- **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 (not calendar years). System seed only creates stored `points` — product computed metrics (e.g. `days`) are host-seeded after registering anchors.
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.
124
125
 
125
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.
126
127
 
127
- **Leaderboards** — `getMetricLeaderboard` / `GET /api/achievements/leaderboard?metric=points` ranks stored balances (authenticated). Disable with `endpoints.leaderboard: false`.
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.
129
+
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.
131
+
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.
128
133
 
129
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`.
130
135
 
@@ -226,15 +231,59 @@ achievementPlugin({
226
231
  | --- | --- | --- |
227
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. |
228
233
 
234
+ ### `subjects`
235
+
236
+ Host documents that logs may point at via a polymorphic `subject` field (same idea as `requiresActor` / `requiresMetric`).
237
+
238
+ | Option | Default | Notes |
239
+ | --- | --- | --- |
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
+ ```
277
+
229
278
  ### `endpoints`
230
279
 
231
280
  | Option | Default | Notes |
232
281
  | --- | --- | --- |
233
- | `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. |
234
- | `leaderboard` | `'/achievements/leaderboard'` | Registers `GET /api/achievements/leaderboard?metric=<slug>` for authenticated users (stored metrics only). Pass `false` to skip. |
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. |
235
284
  | `reconcile` | `'/achievements/reconcile'` | Registers `POST /api/achievements/reconcile` for reviewers. Pass `false` to skip. |
236
285
 
237
- Query params (`me`): `scope`, `limit`, `page`, `requestsLimit`. Leaderboard: `metric` (required), `scope`, `limit`, `page`.
286
+ Query params (`me`): `scope`, `limit`, `page`, `requestsLimit`, `reviews`. Leaderboard: `metric` (required), `scope`, `limit`, `page`.
238
287
 
239
288
  ### `extensions.ruleTypes` / `metricAnchors`
240
289
 
@@ -246,7 +295,7 @@ achievementPlugin({
246
295
  extensions: {
247
296
  metricAnchors: {
248
297
  'membership-granted': {
249
- // Admin UI i18n (Payload label), not a CMS localized field
298
+ // Admin UI i18n (Payload label / OptionLabel)
250
299
  label: ({ t }) => t('custom:achievements:anchors:membershipGranted'),
251
300
  resolve: async ({ payload, userId, scopeId }) => {
252
301
  // return a Date from host data, or null
@@ -271,7 +320,7 @@ achievementPlugin({
271
320
 
272
321
  ## Server helpers
273
322
 
274
- Use these from hooks, jobs, and trusted endpoints — not from the public client:
323
+ Use these from hooks, jobs, and trusted endpoints:
275
324
 
276
325
  ```ts
277
326
  import {
@@ -285,14 +334,34 @@ import {
285
334
  resolveCurrentTier,
286
335
  resolveTierProgress,
287
336
  getUserProgress,
337
+ loadUserProgressReviews,
338
+ buildUnlockRequirementLeaves,
288
339
  submitAchievementRequest,
289
340
  reviewAchievementRequest,
341
+ logSubject,
290
342
  } from '@dappermountain/payload-plugin-achievements'
291
343
 
292
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
+ })
293
353
  await recordMetricChange({ payload, userId, scopeId, metric: 'points', change: 10 })
354
+ const points = await resolveMetricValue({ payload, userId, scopeId, metric: 'points' })
294
355
  const board = await getMetricLeaderboard({ payload, metric: 'points', scopeId, limit: 20 })
295
356
  const tier = await resolveCurrentTier({ payload, userId, scopeId })
357
+ const progress = await getUserProgress({ payload, userId, scopeId, include: { reviews: true } })
358
+ const unlockLeaves = await buildUnlockRequirementLeaves({
359
+ payload,
360
+ userId,
361
+ scopeId,
362
+ rules: nextTier.unlockRules,
363
+ ladderRank: tier?.rank ?? null,
364
+ })
296
365
 
297
366
  // Repair users whose grants predate side-effect hooks (idempotent)
298
367
  await reconcileProgression({ payload })
@@ -326,6 +395,8 @@ Engine log `data` for those system events uses a shared transition envelope:
326
395
  | `achievement.granted` | `null` | achievement |
327
396
  | `achievement.revoked` | achievement | `null` |
328
397
 
398
+ Host document links belong on polymorphic **`subject`** (when `subjects.collections` is set), not inside `data`.
399
+
329
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).
330
401
 
331
402
  **Admin:** Grants list includes **Repair progression**, which `POST`s the reconcile endpoint (requires `canReview`).
@@ -334,7 +405,7 @@ Turning **Requires review** off on a definition or tier **approves pending reque
334
405
 
335
406
  | Goal | Request |
336
407
  | --- | --- |
337
- | Current user snapshot | `GET /api/achievements/me?scope=<scopeId>&limit=10&page=1` |
408
+ | Current user snapshot | `GET /api/achievements/me?scope=<scopeId>&limit=10&page=1` (`&reviews=1` for pending/rejected review keys) |
338
409
  | Stored metric leaderboard | `GET /api/achievements/leaderboard?metric=points&scope=<scopeId>&limit=20&page=1` |
339
410
  | Repair progression | `POST /api/achievements/reconcile` (reviewer; optional `{ userId, scopeId, achievementId\|achievementSlug, tierId\|tierSlug, limit }`) |
340
411
  | List grants | `GET /api/achievement-grants?where[user][equals]=<userId>` |
@@ -357,7 +428,15 @@ import {
357
428
  await seedAchievementCatalog(payload) // system metrics + event types (English)
358
429
 
359
430
  await seedAchievements(payload, {
360
- eventTypes: [{ name: 'Custom kind', slug: 'host.custom-kind', requiresActor: true }],
431
+ eventTypes: [
432
+ { name: 'Custom kind', slug: 'host.custom-kind', requiresActor: true },
433
+ {
434
+ name: 'Comment created',
435
+ slug: 'comment.created',
436
+ requiresSubject: true,
437
+ subjectRelationTo: ['comments'],
438
+ },
439
+ ],
361
440
  metrics: [{ name: 'Streak days', slug: 'streak-days' }],
362
441
  tiers: [
363
442
  {
@@ -0,0 +1,14 @@
1
+ import type { RelationshipFieldClientProps } from 'payload';
2
+ import React from 'react';
3
+ type SubjectFieldProps = RelationshipFieldClientProps & {
4
+ eventTypesSlug: string;
5
+ /** Plugin allowlist — used when the event type leaves `subjectRelationTo` empty. */
6
+ subjectCollections: string[];
7
+ };
8
+ /**
9
+ * Polymorphic subject — only when the selected event type requires a host document.
10
+ * Clears the value when the type does not require a subject.
11
+ */
12
+ export declare function SubjectField(props: SubjectFieldProps): React.JSX.Element | null;
13
+ export {};
14
+ //# sourceMappingURL=SubjectField.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SubjectField.d.ts","sourceRoot":"","sources":["../../src/admin/SubjectField.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAA;AAE3D,OAAO,KAA6B,MAAM,OAAO,CAAA;AAIjD,KAAK,iBAAiB,GAAG,4BAA4B,GAAG;IACtD,cAAc,EAAE,MAAM,CAAA;IACtB,oFAAoF;IACpF,kBAAkB,EAAE,MAAM,EAAE,CAAA;CAC7B,CAAA;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,4BAoCpD"}
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { RelationshipField, useField } from '@payloadcms/ui';
4
+ import { useEffect, useMemo } from 'react';
5
+ import { useEventTypeFlags } from './useEventTypeFlags.js';
6
+ /**
7
+ * Polymorphic subject — only when the selected event type requires a host document.
8
+ * Clears the value when the type does not require a subject.
9
+ */
10
+ export function SubjectField(props) {
11
+ const { eventTypesSlug, subjectCollections, ...fieldArgs } = props;
12
+ const { requiresSubject, subjectRelationTo } = useEventTypeFlags(eventTypesSlug);
13
+ const { setValue, value } = useField({ path: fieldArgs.path });
14
+ const allowedCollections = useMemo(() => {
15
+ if (subjectRelationTo.length > 0) {
16
+ return subjectRelationTo.filter((slug) => subjectCollections.includes(slug));
17
+ }
18
+ return subjectCollections;
19
+ }, [subjectCollections, subjectRelationTo]);
20
+ useEffect(() => {
21
+ if (!requiresSubject && value != null && value !== '') {
22
+ setValue(null);
23
+ return;
24
+ }
25
+ if (!requiresSubject || !value || typeof value !== 'object')
26
+ return;
27
+ const relationTo = value.relationTo;
28
+ if (typeof relationTo === 'string' && allowedCollections.length > 0 && !allowedCollections.includes(relationTo)) {
29
+ setValue(null);
30
+ }
31
+ }, [requiresSubject, setValue, value, allowedCollections]);
32
+ if (!requiresSubject || allowedCollections.length === 0)
33
+ return null;
34
+ return (_jsx(RelationshipField, { ...fieldArgs, field: {
35
+ ...fieldArgs.field,
36
+ relationTo: allowedCollections,
37
+ } }));
38
+ }
@@ -1,6 +1,8 @@
1
1
  export type EventTypeFlags = {
2
2
  requiresActor: boolean;
3
3
  requiresMetric: boolean;
4
+ requiresSubject: boolean;
5
+ subjectRelationTo: string[];
4
6
  };
5
7
  /** Resolve event-type flags for the selected `type` field (fetch when only an id is present). */
6
8
  export declare function useEventTypeFlags(eventTypesSlug: string): EventTypeFlags;
@@ -1 +1 @@
1
- {"version":3,"file":"useEventTypeFlags.d.ts","sourceRoot":"","sources":["../../src/admin/useEventTypeFlags.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,cAAc,GAAG;IAC3B,aAAa,EAAE,OAAO,CAAA;IACtB,cAAc,EAAE,OAAO,CAAA;CACxB,CAAA;AA2BD,iGAAiG;AACjG,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,cAAc,CAyCxE"}
1
+ {"version":3,"file":"useEventTypeFlags.d.ts","sourceRoot":"","sources":["../../src/admin/useEventTypeFlags.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,cAAc,GAAG;IAC3B,aAAa,EAAE,OAAO,CAAA;IACtB,cAAc,EAAE,OAAO,CAAA;IACvB,eAAe,EAAE,OAAO,CAAA;IACxB,iBAAiB,EAAE,MAAM,EAAE,CAAA;CAC5B,CAAA;AAqDD,iGAAiG;AACjG,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,cAAc,CA4CxE"}
@@ -15,19 +15,35 @@ function relationId(value) {
15
15
  }
16
16
  return null;
17
17
  }
18
+ function readSubjectRelationTo(value) {
19
+ if (!Array.isArray(value))
20
+ return [];
21
+ return value.filter((entry) => typeof entry === 'string' && entry.trim().length > 0);
22
+ }
18
23
  function flagsFromValue(typeValue) {
19
24
  if (!typeValue || typeof typeValue !== 'object')
20
25
  return null;
21
- if (!('requiresActor' in typeValue || 'requiresMetric' in typeValue || 'slug' in typeValue)) {
26
+ if (!('requiresActor' in typeValue ||
27
+ 'requiresMetric' in typeValue ||
28
+ 'requiresSubject' in typeValue ||
29
+ 'subjectRelationTo' in typeValue ||
30
+ 'slug' in typeValue)) {
22
31
  return null;
23
32
  }
24
33
  const row = typeValue;
25
34
  return {
26
35
  requiresActor: Boolean(row.requiresActor),
27
36
  requiresMetric: Boolean(row.requiresMetric) || row.slug === 'metric.delta',
37
+ requiresSubject: Boolean(row.requiresSubject),
38
+ subjectRelationTo: readSubjectRelationTo(row.subjectRelationTo),
28
39
  };
29
40
  }
30
- const empty = { requiresActor: false, requiresMetric: false };
41
+ const empty = {
42
+ requiresActor: false,
43
+ requiresMetric: false,
44
+ requiresSubject: false,
45
+ subjectRelationTo: [],
46
+ };
31
47
  /** Resolve event-type flags for the selected `type` field (fetch when only an id is present). */
32
48
  export function useEventTypeFlags(eventTypesSlug) {
33
49
  const { config } = useConfig();
@@ -47,7 +63,9 @@ export function useEventTypeFlags(eventTypesSlug) {
47
63
  const controller = new AbortController();
48
64
  const api = config.routes?.api ?? '/api';
49
65
  const serverURL = config.serverURL ?? '';
50
- const url = `${serverURL}${api}/${eventTypesSlug}/${typeId}?depth=0&select[requiresActor]=true&select[requiresMetric]=true&select[slug]=true`;
66
+ const url = `${serverURL}${api}/${eventTypesSlug}/${typeId}` +
67
+ `?depth=0&select[requiresActor]=true&select[requiresMetric]=true` +
68
+ `&select[requiresSubject]=true&select[subjectRelationTo]=true&select[slug]=true`;
51
69
  void fetch(url, { credentials: 'include', signal: controller.signal })
52
70
  .then(async (res) => {
53
71
  if (!res.ok) {
@@ -12,7 +12,7 @@ export declare function runReconcileCli(args: {
12
12
  config: SanitizedConfig | Promise<SanitizedConfig>;
13
13
  argv?: string[];
14
14
  filters?: ReconcileCliFilters;
15
- /** When false, return the result instead of exiting the process. Default true. */
15
+ /** When false, return the result and leave the process running. Default true. */
16
16
  exit?: boolean;
17
17
  }): Promise<ReconcileProgressionResult>;
18
18
  //# sourceMappingURL=reconcile.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/cli/reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,SAAS,CAAA;AAE1D,OAAO,EAEL,KAAK,wBAAwB,EAC7B,KAAK,0BAA0B,EAChC,MAAM,sCAAsC,CAAA;AAE7C,MAAM,MAAM,mBAAmB,GAAG,IAAI,CAAC,wBAAwB,EAAE,SAAS,GAAG,KAAK,CAAC,CAAA;AA2BnF,sFAAsF;AACtF,wBAAgB,qBAAqB,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,mBAAmB,CAUjG;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CAc5C;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAC1C,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAA;IAClD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;IACf,OAAO,CAAC,EAAE,mBAAmB,CAAA;IAC7B,kFAAkF;IAClF,IAAI,CAAC,EAAE,OAAO,CAAA;CACf,GAAG,OAAO,CAAC,0BAA0B,CAAC,CA4BtC"}
1
+ {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/cli/reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,SAAS,CAAA;AAE1D,OAAO,EAEL,KAAK,wBAAwB,EAC7B,KAAK,0BAA0B,EAChC,MAAM,sCAAsC,CAAA;AAE7C,MAAM,MAAM,mBAAmB,GAAG,IAAI,CAAC,wBAAwB,EAAE,SAAS,GAAG,KAAK,CAAC,CAAA;AA2BnF,sFAAsF;AACtF,wBAAgB,qBAAqB,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,mBAAmB,CAUjG;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CAc5C;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAC1C,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAA;IAClD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;IACf,OAAO,CAAC,EAAE,mBAAmB,CAAA;IAC7B,iFAAiF;IACjF,IAAI,CAAC,EAAE,OAAO,CAAA;CACf,GAAG,OAAO,CAAC,0BAA0B,CAAC,CA4BtC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/EventTypes/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAS,MAAM,SAAS,CAAA;AAOtD,wBAAgB,yBAAyB,IAAI,gBAAgB,CAgE5D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/EventTypes/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAS,MAAM,SAAS,CAAA;AAqCtD,wBAAgB,yBAAyB,IAAI,gBAAgB,CAoE5D"}
@@ -1,8 +1,36 @@
1
+ import { getAchievementOptions } from '../../options-store.js';
1
2
  import { collectionAdmin, collectionOf, columnsWithOptionalScope, optionalFields, slugOf } from '../helpers.js';
2
3
  import { localizedNameField, scopeField } from '../fields/index.js';
3
4
  import { access } from './access/index.js';
4
5
  import { hooks } from './hooks/index.js';
6
+ function subjectCatalogFields() {
7
+ const collections = getAchievementOptions().subjectCollections;
8
+ if (collections.length === 0)
9
+ return [];
10
+ return [
11
+ {
12
+ name: 'requiresSubject',
13
+ type: 'checkbox',
14
+ defaultValue: false,
15
+ label: 'Requires a subject',
16
+ admin: {
17
+ description: 'Turn this on when the log points at a host document (for example a post or comment). We’ll ask for that document when it’s logged.',
18
+ },
19
+ },
20
+ {
21
+ name: 'subjectRelationTo',
22
+ type: 'select',
23
+ hasMany: true,
24
+ options: collections.map((slug) => ({ label: slug, value: slug })),
25
+ admin: {
26
+ condition: (_, siblingData) => Boolean(siblingData?.requiresSubject),
27
+ description: 'Which host collections this type may reference. Leave empty to allow any collection from the plugin subjects allowlist.',
28
+ },
29
+ },
30
+ ];
31
+ }
5
32
  export function buildEventTypesCollection() {
33
+ const subjectFields = subjectCatalogFields();
6
34
  return {
7
35
  slug: slugOf('eventTypes'),
8
36
  access: { ...access },
@@ -14,6 +42,7 @@ export function buildEventTypesCollection() {
14
42
  'slug',
15
43
  'requiresActor',
16
44
  'requiresMetric',
45
+ ...(subjectFields.length > 0 ? ['requiresSubject'] : []),
17
46
  'system',
18
47
  'scope',
19
48
  ]),
@@ -46,6 +75,7 @@ export function buildEventTypesCollection() {
46
75
  description: 'Turn this on when the entry changes a score (like points). We’ll ask which metric and by how much.',
47
76
  },
48
77
  },
78
+ ...subjectFields,
49
79
  {
50
80
  name: 'system',
51
81
  type: 'checkbox',
@@ -2,4 +2,5 @@ import type { Validate } from 'payload';
2
2
  export declare const validateActor: Validate;
3
3
  export declare const validateMetric: Validate;
4
4
  export declare const validateChange: Validate;
5
+ export declare const validateSubject: Validate;
5
6
  //# sourceMappingURL=validators.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../../../src/collections/Logs/fields/validators.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AASvC,eAAO,MAAM,aAAa,EAAE,QAW3B,CAAA;AAED,eAAO,MAAM,cAAc,EAAE,QA0B5B,CAAA;AAED,eAAO,MAAM,cAAc,EAAE,QAW5B,CAAA"}
1
+ {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../../../src/collections/Logs/fields/validators.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAavC,eAAO,MAAM,aAAa,EAAE,QAW3B,CAAA;AAED,eAAO,MAAM,cAAc,EAAE,QA0B5B,CAAA;AAED,eAAO,MAAM,cAAc,EAAE,QAW5B,CAAA;AAED,eAAO,MAAM,eAAe,EAAE,QAqC7B,CAAA"}
@@ -1,5 +1,7 @@
1
+ import { getAchievementOptions } from '../../../options-store.js';
1
2
  import { collectionOf } from '../../../collections/helpers.js';
2
- import { eventTypeRequiresActor, eventTypeRequiresMetric, } from '../../../services/achievement/eventTypeRequiresActor.js';
3
+ import { eventTypeRequiresActor, eventTypeRequiresMetric, eventTypeSubjectRelationTo, getEventTypeFlags, } from '../../../services/achievement/eventTypeRequiresActor.js';
4
+ import { readLogSubject } from '../../../services/achievement/logSubject.js';
3
5
  import { relationId } from '../../../services/achievement/relationId.js';
4
6
  export const validateActor = async (value, { data, req }) => {
5
7
  if (!req?.payload)
@@ -55,3 +57,37 @@ export const validateChange = async (value, { data, req }) => {
55
57
  }
56
58
  return true;
57
59
  };
60
+ export const validateSubject = async (value, { data, req }) => {
61
+ if (!req?.payload)
62
+ return true;
63
+ const allowlist = getAchievementOptions().subjectCollections;
64
+ if (allowlist.length === 0)
65
+ return true;
66
+ const typeId = relationId(data?.type);
67
+ if (!typeId)
68
+ return true;
69
+ const flags = await getEventTypeFlags({
70
+ payload: req.payload,
71
+ req,
72
+ typeIdOrDoc: typeId,
73
+ });
74
+ const subject = readLogSubject(value);
75
+ if (!flags.requiresSubject) {
76
+ return true;
77
+ }
78
+ if (!subject) {
79
+ return 'This log type needs a subject (the host document it is about).';
80
+ }
81
+ if (!allowlist.includes(subject.relationTo)) {
82
+ return `Subject collection "${subject.relationTo}" is not in the plugin subjects allowlist.`;
83
+ }
84
+ const allowed = await eventTypeSubjectRelationTo({
85
+ payload: req.payload,
86
+ req,
87
+ typeIdOrDoc: typeId,
88
+ });
89
+ if (allowed.length > 0 && !allowed.includes(subject.relationTo)) {
90
+ return `This log type only allows subjects from: ${allowed.join(', ')}.`;
91
+ }
92
+ return true;
93
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"ensureTypeRequirements.d.ts","sourceRoot":"","sources":["../../../../src/collections/Logs/hooks/ensureTypeRequirements.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAA;AAU3D,eAAO,MAAM,sBAAsB,EAAE,4BAmDpC,CAAA"}
1
+ {"version":3,"file":"ensureTypeRequirements.d.ts","sourceRoot":"","sources":["../../../../src/collections/Logs/hooks/ensureTypeRequirements.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAA;AAc3D,eAAO,MAAM,sBAAsB,EAAE,4BA+DpC,CAAA"}
@@ -1,6 +1,8 @@
1
1
  import { APIError } from 'payload';
2
+ import { getAchievementOptions } from '../../../options-store.js';
2
3
  import { collectionOf } from '../../../collections/helpers.js';
3
- import { eventTypeRequiresActor, eventTypeRequiresMetric, } from '../../../services/achievement/eventTypeRequiresActor.js';
4
+ import { eventTypeSubjectRelationTo, getEventTypeFlags, } from '../../../services/achievement/eventTypeRequiresActor.js';
5
+ import { readLogSubject } from '../../../services/achievement/logSubject.js';
4
6
  import { relationId } from '../../../services/achievement/relationId.js';
5
7
  export const ensureTypeRequirements = async ({ data, req }) => {
6
8
  if (!data)
@@ -8,18 +10,11 @@ export const ensureTypeRequirements = async ({ data, req }) => {
8
10
  const typeId = relationId(data.type);
9
11
  if (!typeId)
10
12
  return data;
11
- const flags = {
12
- requiresActor: await eventTypeRequiresActor({
13
- payload: req.payload,
14
- req,
15
- typeIdOrDoc: typeId,
16
- }),
17
- requiresMetric: await eventTypeRequiresMetric({
18
- payload: req.payload,
19
- req,
20
- typeIdOrDoc: typeId,
21
- }),
22
- };
13
+ const flags = await getEventTypeFlags({
14
+ payload: req.payload,
15
+ req,
16
+ typeIdOrDoc: typeId,
17
+ });
23
18
  if (flags.requiresActor && !relationId(data.actor)) {
24
19
  throw new APIError('This log type needs an actor (who caused it).', 400);
25
20
  }
@@ -44,5 +39,19 @@ export const ensureTypeRequirements = async ({ data, req }) => {
44
39
  throw new APIError('Computed metrics cannot be changed with a metric delta. Use a stored metric such as Points.', 400);
45
40
  }
46
41
  }
42
+ if (flags.requiresSubject && getAchievementOptions().subjectCollections.length > 0) {
43
+ const subject = readLogSubject(data.subject);
44
+ if (!subject) {
45
+ throw new APIError('This log type needs a subject (the host document it is about).', 400);
46
+ }
47
+ const allowed = await eventTypeSubjectRelationTo({
48
+ payload: req.payload,
49
+ req,
50
+ typeIdOrDoc: typeId,
51
+ });
52
+ if (allowed.length > 0 && !allowed.includes(subject.relationTo)) {
53
+ throw new APIError(`This log type only allows subjects from: ${allowed.join(', ')}.`, 400);
54
+ }
55
+ }
47
56
  return data;
48
57
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Logs/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAS,MAAM,SAAS,CAAA;AAUtD,+EAA+E;AAC/E,wBAAgB,mBAAmB,IAAI,gBAAgB,CA6GtD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Logs/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAS,MAAM,SAAS,CAAA;AAqCtD,+EAA+E;AAC/E,wBAAgB,mBAAmB,IAAI,gBAAgB,CAuHtD"}
@@ -3,12 +3,34 @@ import { collectionAdmin, collectionOf, optionalFields, slugOf } from '../helper
3
3
  import { getAchievementOptions } from '../../options-store.js';
4
4
  import { scopeField, userField } from '../fields/index.js';
5
5
  import { access } from './access/index.js';
6
- import { validateActor, validateChange, validateMetric } from './fields/validators.js';
6
+ import { validateActor, validateChange, validateMetric, validateSubject, } from './fields/validators.js';
7
7
  import { hooks } from './hooks/index.js';
8
+ function subjectLogField(eventTypesSlug) {
9
+ const collections = getAchievementOptions().subjectCollections;
10
+ if (collections.length === 0)
11
+ return null;
12
+ return {
13
+ name: 'subject',
14
+ type: 'relationship',
15
+ relationTo: collections,
16
+ index: true,
17
+ validate: validateSubject,
18
+ admin: {
19
+ description: 'Host document this entry is about (post, comment, …).',
20
+ components: {
21
+ Field: {
22
+ path: '@dappermountain/payload-plugin-achievements/client#SubjectField',
23
+ clientProps: { eventTypesSlug, subjectCollections: collections },
24
+ },
25
+ },
26
+ },
27
+ };
28
+ }
8
29
  /** Append-only occurrence log. `type` / `metric` are catalog relationships. */
9
30
  export function buildLogsCollection() {
10
31
  const users = getAchievementOptions().usersCollectionSlug ?? 'users';
11
32
  const eventTypesSlug = collectionOf('eventTypes');
33
+ const subjectField = subjectLogField(eventTypesSlug);
12
34
  return {
13
35
  slug: slugOf('logs'),
14
36
  labels: {
@@ -19,7 +41,15 @@ export function buildLogsCollection() {
19
41
  hooks,
20
42
  admin: collectionAdmin({
21
43
  useAsTitle: 'id',
22
- defaultColumns: ['type', 'user', 'actor', 'metric', 'change', 'createdAt'],
44
+ defaultColumns: [
45
+ 'type',
46
+ 'user',
47
+ 'actor',
48
+ ...(subjectField ? ['subject'] : []),
49
+ 'metric',
50
+ 'change',
51
+ 'createdAt',
52
+ ],
23
53
  description: 'Member activity history — grants, revokes, score changes, tier moves, and host custom events. Not a second grant store.',
24
54
  }),
25
55
  fields: [
@@ -88,6 +118,7 @@ export function buildLogsCollection() {
88
118
  },
89
119
  ],
90
120
  },
121
+ ...(subjectField ? [subjectField] : []),
91
122
  {
92
123
  type: 'collapsible',
93
124
  label: 'Details',
@@ -105,7 +136,7 @@ export function buildLogsCollection() {
105
136
  name: 'data',
106
137
  type: 'json',
107
138
  admin: {
108
- description: 'Engine transition envelope: `{ from, to }` each `{ id, slug? }` or null (tier.changed, achievement.granted/revoked). Hosts may add extra keys.',
139
+ description: 'Engine transition envelope: `{ from, to }` each `{ id, slug? }` or null (tier.changed, achievement.granted/revoked). Prefer `subject` for host document links.',
109
140
  },
110
141
  },
111
142
  ],
@@ -4,13 +4,15 @@ export declare const DEFAULT_USERS_COLLECTION = "users";
4
4
  export declare const DEFAULT_ME_ENDPOINT_PATH = "/achievements/me";
5
5
  export declare const DEFAULT_LEADERBOARD_ENDPOINT_PATH = "/achievements/leaderboard";
6
6
  export declare const DEFAULT_RECONCILE_ENDPOINT_PATH = "/achievements/reconcile";
7
- export type ResolvedAchievementOptions = Omit<AchievementPluginOptions, 'users' | 'endpoints'> & {
7
+ export type ResolvedAchievementOptions = Omit<AchievementPluginOptions, 'users' | 'endpoints' | 'subjects'> & {
8
8
  enabled: boolean;
9
9
  seedSystemCatalog: boolean;
10
10
  usersCollectionSlug: string;
11
11
  users: {
12
12
  includeJoins: boolean;
13
13
  };
14
+ /** Host collections allowed as polymorphic log subjects (empty = feature off). */
15
+ subjectCollections: string[];
14
16
  /** Resolved collection slug map (after prefix + overrides). */
15
17
  collectionSlugs: ReturnType<typeof resolveCollectionSlugs>;
16
18
  /** Admin nav group for plugin collections (`false` = ungrouped). */
@@ -21,6 +23,8 @@ export type ResolvedAchievementOptions = Omit<AchievementPluginOptions, 'users'
21
23
  reconcile: string | false;
22
24
  };
23
25
  };
26
+ /** Unique, non-empty collection slugs from plugin `subjects.collections`. */
27
+ export declare function resolveSubjectCollections(options?: AchievementPluginOptions): string[];
24
28
  export declare function resolveMeEndpointPath(me?: string | false): string | false;
25
29
  export declare function resolveLeaderboardEndpointPath(path?: string | false): string | false;
26
30
  export declare function resolveReconcileEndpointPath(path?: string | false): string | false;
@@ -1 +1 @@
1
- {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../src/defaults.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;AAC1D,OAAO,EAEL,sBAAsB,EACvB,MAAM,wBAAwB,CAAA;AAE/B,eAAO,MAAM,wBAAwB,UAAU,CAAA;AAC/C,eAAO,MAAM,wBAAwB,qBAAqB,CAAA;AAC1D,eAAO,MAAM,iCAAiC,8BAA8B,CAAA;AAC5E,eAAO,MAAM,+BAA+B,4BAA4B,CAAA;AAExE,MAAM,MAAM,0BAA0B,GAAG,IAAI,CAAC,wBAAwB,EAAE,OAAO,GAAG,WAAW,CAAC,GAAG;IAC/F,OAAO,EAAE,OAAO,CAAA;IAChB,iBAAiB,EAAE,OAAO,CAAA;IAC1B,mBAAmB,EAAE,MAAM,CAAA;IAC3B,KAAK,EAAE;QAAE,YAAY,EAAE,OAAO,CAAA;KAAE,CAAA;IAChC,+DAA+D;IAC/D,eAAe,EAAE,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAA;IAC1D,oEAAoE;IACpE,UAAU,EAAE,MAAM,GAAG,KAAK,CAAA;IAC1B,SAAS,EAAE;QAAE,EAAE,EAAE,MAAM,GAAG,KAAK,CAAC;QAAC,WAAW,EAAE,MAAM,GAAG,KAAK,CAAC;QAAC,SAAS,EAAE,MAAM,GAAG,KAAK,CAAA;KAAE,CAAA;CAC1F,CAAA;AAQD,wBAAgB,qBAAqB,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,CAIzE;AAED,wBAAgB,8BAA8B,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,CAIpF;AAED,wBAAgB,4BAA4B,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,CAIlF;AAED,wBAAgB,cAAc,CAAC,OAAO,GAAE,wBAA6B,GAAG,0BAA0B,CAiBjG"}
1
+ {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../src/defaults.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;AAC1D,OAAO,EAEL,sBAAsB,EACvB,MAAM,wBAAwB,CAAA;AAE/B,eAAO,MAAM,wBAAwB,UAAU,CAAA;AAC/C,eAAO,MAAM,wBAAwB,qBAAqB,CAAA;AAC1D,eAAO,MAAM,iCAAiC,8BAA8B,CAAA;AAC5E,eAAO,MAAM,+BAA+B,4BAA4B,CAAA;AAExE,MAAM,MAAM,0BAA0B,GAAG,IAAI,CAAC,wBAAwB,EAAE,OAAO,GAAG,WAAW,GAAG,UAAU,CAAC,GAAG;IAC5G,OAAO,EAAE,OAAO,CAAA;IAChB,iBAAiB,EAAE,OAAO,CAAA;IAC1B,mBAAmB,EAAE,MAAM,CAAA;IAC3B,KAAK,EAAE;QAAE,YAAY,EAAE,OAAO,CAAA;KAAE,CAAA;IAChC,kFAAkF;IAClF,kBAAkB,EAAE,MAAM,EAAE,CAAA;IAC5B,+DAA+D;IAC/D,eAAe,EAAE,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAA;IAC1D,oEAAoE;IACpE,UAAU,EAAE,MAAM,GAAG,KAAK,CAAA;IAC1B,SAAS,EAAE;QAAE,EAAE,EAAE,MAAM,GAAG,KAAK,CAAC;QAAC,WAAW,EAAE,MAAM,GAAG,KAAK,CAAC;QAAC,SAAS,EAAE,MAAM,GAAG,KAAK,CAAA;KAAE,CAAA;CAC1F,CAAA;AAED,6EAA6E;AAC7E,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,wBAAwB,GAAG,MAAM,EAAE,CAWtF;AAQD,wBAAgB,qBAAqB,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,CAIzE;AAED,wBAAgB,8BAA8B,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,CAIpF;AAED,wBAAgB,4BAA4B,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,CAIlF;AAED,wBAAgB,cAAc,CAAC,OAAO,GAAE,wBAA6B,GAAG,0BAA0B,CAmBjG"}