@dappermountain/payload-plugin-achievements 0.1.0 → 0.2.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 (81) hide show
  1. package/README.md +20 -6
  2. package/dist/admin/ReconcileProgressionButton.d.ts +11 -0
  3. package/dist/admin/ReconcileProgressionButton.d.ts.map +1 -0
  4. package/dist/admin/ReconcileProgressionButton.js +41 -0
  5. package/dist/catalog.d.ts +3 -0
  6. package/dist/catalog.d.ts.map +1 -1
  7. package/dist/catalog.js +1 -0
  8. package/dist/collections/AchievementRequests/hooks/prepareAchievementRequest.d.ts.map +1 -1
  9. package/dist/collections/AchievementRequests/hooks/prepareAchievementRequest.js +35 -15
  10. package/dist/collections/Grants/hooks/afterGrantMutation.d.ts +4 -0
  11. package/dist/collections/Grants/hooks/afterGrantMutation.d.ts.map +1 -0
  12. package/dist/collections/Grants/hooks/afterGrantMutation.js +56 -0
  13. package/dist/collections/Grants/hooks/index.d.ts.map +1 -1
  14. package/dist/collections/Grants/hooks/index.js +3 -0
  15. package/dist/collections/Grants/index.d.ts.map +1 -1
  16. package/dist/collections/Grants/index.js +18 -1
  17. package/dist/collections/Logs/index.js +1 -1
  18. package/dist/collections/TierRequests/access/index.d.ts +3 -0
  19. package/dist/collections/TierRequests/access/index.d.ts.map +1 -0
  20. package/dist/collections/TierRequests/access/index.js +8 -0
  21. package/dist/collections/TierRequests/hooks/index.d.ts +3 -0
  22. package/dist/collections/TierRequests/hooks/index.d.ts.map +1 -0
  23. package/dist/collections/TierRequests/hooks/index.js +5 -0
  24. package/dist/collections/TierRequests/hooks/prepareTierRequest.d.ts +8 -0
  25. package/dist/collections/TierRequests/hooks/prepareTierRequest.d.ts.map +1 -0
  26. package/dist/collections/TierRequests/hooks/prepareTierRequest.js +67 -0
  27. package/dist/collections/TierRequests/index.d.ts +3 -0
  28. package/dist/collections/TierRequests/index.d.ts.map +1 -0
  29. package/dist/collections/TierRequests/index.js +47 -0
  30. package/dist/collections/Tiers/index.d.ts.map +1 -1
  31. package/dist/collections/Tiers/index.js +11 -3
  32. package/dist/collections/index.d.ts.map +1 -1
  33. package/dist/collections/index.js +2 -0
  34. package/dist/collections/slugs.d.ts.map +1 -1
  35. package/dist/collections/slugs.js +1 -0
  36. package/dist/defaults.d.ts +3 -0
  37. package/dist/defaults.d.ts.map +1 -1
  38. package/dist/defaults.js +12 -3
  39. package/dist/endpoints/index.d.ts.map +1 -1
  40. package/dist/endpoints/index.js +9 -3
  41. package/dist/endpoints/reconcile.d.ts +8 -0
  42. package/dist/endpoints/reconcile.d.ts.map +1 -0
  43. package/dist/endpoints/reconcile.js +70 -0
  44. package/dist/exports/client.d.ts +1 -0
  45. package/dist/exports/client.d.ts.map +1 -1
  46. package/dist/exports/client.js +1 -0
  47. package/dist/index.d.ts +3 -3
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +2 -2
  50. package/dist/seed/index.d.ts +2 -0
  51. package/dist/seed/index.d.ts.map +1 -1
  52. package/dist/seed/index.js +1 -0
  53. package/dist/services/achievement/contextFlags.d.ts +5 -0
  54. package/dist/services/achievement/contextFlags.d.ts.map +1 -0
  55. package/dist/services/achievement/contextFlags.js +4 -0
  56. package/dist/services/achievement/grantSideEffects.d.ts +54 -0
  57. package/dist/services/achievement/grantSideEffects.d.ts.map +1 -0
  58. package/dist/services/achievement/grantSideEffects.js +269 -0
  59. package/dist/services/achievement/index.d.ts +4 -0
  60. package/dist/services/achievement/index.d.ts.map +1 -1
  61. package/dist/services/achievement/index.js +3 -0
  62. package/dist/services/achievement/reconcile.d.ts +24 -0
  63. package/dist/services/achievement/reconcile.d.ts.map +1 -0
  64. package/dist/services/achievement/reconcile.js +170 -0
  65. package/dist/services/achievement/recordEvent.d.ts.map +1 -1
  66. package/dist/services/achievement/recordEvent.js +8 -0
  67. package/dist/services/achievement/resolveCurrentTier.d.ts +1 -1
  68. package/dist/services/achievement/resolveCurrentTier.d.ts.map +1 -1
  69. package/dist/services/achievement/resolveCurrentTier.js +12 -2
  70. package/dist/services/achievement/submitAchievementRequest.d.ts +4 -2
  71. package/dist/services/achievement/submitAchievementRequest.d.ts.map +1 -1
  72. package/dist/services/achievement/submitAchievementRequest.js +8 -101
  73. package/dist/services/achievement/tierApproval.d.ts +8 -0
  74. package/dist/services/achievement/tierApproval.d.ts.map +1 -0
  75. package/dist/services/achievement/tierApproval.js +21 -0
  76. package/dist/services/achievement/tierProgression.d.ts +22 -0
  77. package/dist/services/achievement/tierProgression.d.ts.map +1 -0
  78. package/dist/services/achievement/tierProgression.js +100 -0
  79. package/dist/types.d.ts +8 -2
  80. package/dist/types.d.ts.map +1 -1
  81. package/package.json +6 -6
package/README.md CHANGED
@@ -22,11 +22,14 @@ Most gamification features end up as one-off collections and scattered hooks. Th
22
22
 
23
23
  ## Install
24
24
 
25
+ Preferred package manager: **Bun**.
26
+
25
27
  ```bash
26
- npm install @dappermountain/payload-plugin-achievements
27
- # or: pnpm add / yarn add / bun add
28
+ bun add @dappermountain/payload-plugin-achievements
28
29
  ```
29
30
 
31
+ Other clients also work (`npm install @dappermountain/payload-plugin-achievements`, etc.).
32
+
30
33
  ## Quick start
31
34
 
32
35
  ```ts
@@ -80,7 +83,8 @@ Slugs are prefixed with `achievement-` by default so they don’t collide with y
80
83
  | `achievements` | `achievement-definitions` | Things a user can earn (with eligibility / completion rules). |
81
84
  | `grants` | `achievement-grants` | One row per earned achievement per user (+ scope). |
82
85
  | `achievementRequests` | `achievement-requests` | Request → pending / approved / rejected. |
83
- | `logs` | `achievement-logs` | Append-only history of what happened. |
86
+ | `tierRequests` | `achievement-tier-requests` | Tier unlock awaiting review (when the tier has `requiresReview`). |
87
+ | `logs` | `achievement-logs` | Member activity history (grants, revokes, tier moves, metrics, custom events). |
84
88
 
85
89
  **System catalog rows** (seeded by `seedAchievementCatalog`) are protected from deletion and slug changes:
86
90
 
@@ -89,6 +93,7 @@ Slugs are prefixed with `achievement-` by default so they don’t collide with y
89
93
  | `points` | Default metric |
90
94
  | `metric.delta` | Metric change log type (requires metric + change amount) |
91
95
  | `achievement.granted` | Written when a grant is created |
96
+ | `achievement.revoked` | Written when a grant is deleted |
92
97
  | `tier.changed` | Written when derived tier moves |
93
98
 
94
99
  Event types can require an **actor** and/or a **metric + change**. Admin only prompts for those fields when the type needs them.
@@ -102,7 +107,7 @@ Unlock (tiers), eligibility, and completion (achievements) share the same rule t
102
107
  - Built-in leaves → `tier-at-least`, `achievement-complete`, `metric-minimum`, `event-count`
103
108
  - Custom leaves → register via `extensions.ruleTypes`
104
109
 
105
- **Current tier** is computed by walking tiers in `rank` order and evaluating each tier’s unlock rules (`resolveCurrentTier`). **Next-tier fill** uses fractional progress on the same trees (`resolveTierProgress` / `evaluateRuleProgress`):
110
+ **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**. **Next-tier fill** uses fractional progress on the same trees (`resolveTierProgress` / `evaluateRuleProgress`):
106
111
 
107
112
  | Combinator / leaf | Progress behavior |
108
113
  | --- | --- |
@@ -241,6 +246,8 @@ import {
241
246
  recordLog,
242
247
  recordMetricChange,
243
248
  grantAchievement,
249
+ reconcileProgression,
250
+ reconcileUserProgression,
244
251
  resolveCurrentTier,
245
252
  resolveTierProgress,
246
253
  getUserProgress,
@@ -251,19 +258,26 @@ import {
251
258
  await recordLog({ payload, userId, scopeId, type: 'host.custom-kind', actorId })
252
259
  await recordMetricChange({ payload, userId, scopeId, metric: 'points', change: 10 })
253
260
  const tier = await resolveCurrentTier({ payload, userId, scopeId })
261
+
262
+ // Repair users whose grants predate side-effect hooks (idempotent)
263
+ await reconcileProgression({ payload })
254
264
  ```
255
265
 
256
- Pass catalog **slugs** (or ids) for `type` and `metric`. Metric totals are the sum of `change` on matching logs. Recording metrics or granting achievements may append a `tier.changed` log when the derived ladder moves.
266
+ 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`.
267
+
268
+ **Admin:** Grants list includes **Repair progression**, which `POST`s the reconcile endpoint (requires `canReview`).
257
269
 
258
270
  ## REST surface
259
271
 
260
272
  | Goal | Request |
261
273
  | --- | --- |
262
274
  | Current user snapshot | `GET /api/achievements/me?scope=<scopeId>&limit=10&page=1` |
275
+ | Repair progression | `POST /api/achievements/reconcile` (reviewer; optional `{ userId, scopeId, limit }`) |
263
276
  | List grants | `GET /api/achievement-grants?where[user][equals]=<userId>` |
264
277
  | Log history | `GET /api/achievement-logs?where[user][equals]=<userId>` |
265
278
  | Request an achievement | `POST /api/achievement-requests` with `{ achievement }` |
266
- | Approve / reject | `PATCH /api/achievement-requests/:id` with `{ status: "approved" \| "rejected" }` |
279
+ | Approve / reject achievement | `PATCH /api/achievement-requests/:id` with `{ status: "approved" \| "rejected" }` |
280
+ | Approve / reject tier | `PATCH /api/achievement-tier-requests/:id` with `{ status: "approved" \| "rejected" }` |
267
281
  | Browse catalogs | `GET /api/achievement-event-types`, `GET /api/achievement-metrics`, … |
268
282
 
269
283
  Exact collection paths follow your `collections.prefix` / `slugs` settings.
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ type ReconcileProgressionButtonProps = {
3
+ /** Absolute API path including `/api` prefix, e.g. `/api/achievements/reconcile`. */
4
+ reconcilePath: string;
5
+ };
6
+ /**
7
+ * Grants list control — runs progression reconcile for all users with grants.
8
+ */
9
+ export declare function ReconcileProgressionButton(props: ReconcileProgressionButtonProps): React.JSX.Element;
10
+ export {};
11
+ //# sourceMappingURL=ReconcileProgressionButton.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ReconcileProgressionButton.d.ts","sourceRoot":"","sources":["../../src/admin/ReconcileProgressionButton.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAgC,MAAM,OAAO,CAAA;AAEpD,KAAK,+BAA+B,GAAG;IACrC,qFAAqF;IACrF,aAAa,EAAE,MAAM,CAAA;CACtB,CAAA;AAED;;GAEG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,+BAA+B,qBAoDhF"}
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Button, toast, useConfig } from '@payloadcms/ui';
4
+ import { useCallback, useState } from 'react';
5
+ /**
6
+ * Grants list control — runs progression reconcile for all users with grants.
7
+ */
8
+ export function ReconcileProgressionButton(props) {
9
+ const { reconcilePath } = props;
10
+ const { config } = useConfig();
11
+ const [busy, setBusy] = useState(false);
12
+ const onClick = useCallback(async () => {
13
+ if (busy)
14
+ return;
15
+ setBusy(true);
16
+ try {
17
+ const serverURL = config.serverURL?.replace(/\/$/, '') ?? '';
18
+ const url = `${serverURL}${reconcilePath.startsWith('/') ? reconcilePath : `/${reconcilePath}`}`;
19
+ const res = await fetch(url, {
20
+ method: 'POST',
21
+ credentials: 'include',
22
+ headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify({}),
24
+ });
25
+ const json = (await res.json().catch(() => null));
26
+ if (!res.ok) {
27
+ const message = json?.errors?.[0]?.message ?? `Request failed (${res.status})`;
28
+ toast.error(message);
29
+ return;
30
+ }
31
+ toast.success(`Repair complete — users ${json?.usersScanned ?? 0}, grants ${json?.compositesGranted ?? 0}, logs ${json?.grantedLogsBackfilled ?? 0}, requests ${json?.achievementRequestsEnsured ?? 0}, tier requests ${json?.tierRequestsEnsured ?? 0}`);
32
+ }
33
+ catch (error) {
34
+ toast.error(error instanceof Error ? error.message : 'Repair failed');
35
+ }
36
+ finally {
37
+ setBusy(false);
38
+ }
39
+ }, [busy, config.serverURL, reconcilePath]);
40
+ return (_jsx("div", { style: { marginBottom: '1rem' }, children: _jsx(Button, { buttonStyle: "secondary", disabled: busy, onClick: () => void onClick(), children: busy ? 'Repairing…' : 'Repair progression' }) }));
41
+ }
package/dist/catalog.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export declare const ENGINE_EVENT_TYPES: readonly [{
2
2
  readonly slug: "achievement.granted";
3
3
  readonly name: "Achievement granted";
4
+ }, {
5
+ readonly slug: "achievement.revoked";
6
+ readonly name: "Achievement revoked";
4
7
  }, {
5
8
  readonly slug: "metric.delta";
6
9
  readonly name: "Metric delta";
@@ -1 +1 @@
1
- {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB;;;;;;;;;;EAIrB,CAAA;AAEV,eAAO,MAAM,cAAc;;;EAAgD,CAAA;AAE3E,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAA;AAC7E,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAA;AAEtE,eAAO,MAAM,+BAA+B,EAAE,gBAA2B,CAAA;AAEzE,+DAA+D;AAC/D,eAAO,MAAM,0BAA0B,UAAkC,CAAA"}
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;EAKrB,CAAA;AAEV,eAAO,MAAM,cAAc;;;EAAgD,CAAA;AAE3E,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAA;AAC7E,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAA;AAEtE,eAAO,MAAM,+BAA+B,EAAE,gBAA2B,CAAA;AAEzE,+DAA+D;AAC/D,eAAO,MAAM,0BAA0B,UAAkC,CAAA"}
package/dist/catalog.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export const ENGINE_EVENT_TYPES = [
2
2
  { slug: 'achievement.granted', name: 'Achievement granted' },
3
+ { slug: 'achievement.revoked', name: 'Achievement revoked' },
3
4
  { slug: 'metric.delta', name: 'Metric delta', requiresMetric: true },
4
5
  { slug: 'tier.changed', name: 'Tier changed' },
5
6
  ];
@@ -1 +1 @@
1
- {"version":3,"file":"prepareAchievementRequest.d.ts","sourceRoot":"","sources":["../../../../src/collections/AchievementRequests/hooks/prepareAchievementRequest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,yBAAyB,EACzB,4BAA4B,EAE7B,MAAM,SAAS,CAAA;AA0ChB;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,4BAqDvC,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,sBAAsB,EAAE,yBAsCpC,CAAA"}
1
+ {"version":3,"file":"prepareAchievementRequest.d.ts","sourceRoot":"","sources":["../../../../src/collections/AchievementRequests/hooks/prepareAchievementRequest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,yBAAyB,EACzB,4BAA4B,EAE7B,MAAM,SAAS,CAAA;AA2ChB;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,4BAqEvC,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,sBAAsB,EAAE,yBAsCpC,CAAA"}
@@ -1,5 +1,6 @@
1
1
  import { APIError } from 'payload';
2
2
  import { collectionOf } from '../../../collections/helpers';
3
+ import { CTX_SYSTEM_REQUEST } from '../../../services/achievement/contextFlags';
3
4
  import { evaluateRules, ruleGroupHasProgressRequirements } from '../../../services/achievement/evaluateRules';
4
5
  import { grantAchievement } from '../../../services/achievement/submitAchievementRequest';
5
6
  import { relationId } from '../../../services/achievement/relationId';
@@ -34,30 +35,49 @@ export const prepareAchievementRequest = async ({ data, operation, req, original
34
35
  if (!data)
35
36
  return data;
36
37
  if (operation === 'create') {
37
- const userId = req.user?.id;
38
- if (typeof userId !== 'string')
39
- throw new APIError('Unauthorized', 401);
40
- data.user = userId;
38
+ const system = Boolean(req.context?.[CTX_SYSTEM_REQUEST]);
39
+ if (!system) {
40
+ const userId = req.user?.id;
41
+ if (typeof userId !== 'string')
42
+ throw new APIError('Unauthorized', 401);
43
+ data.user = userId;
44
+ }
45
+ else {
46
+ const bound = relationId(data.user);
47
+ if (!bound)
48
+ throw new APIError('user required', 400);
49
+ data.user = bound;
50
+ }
51
+ const userId = relationId(data.user);
52
+ if (!userId)
53
+ throw new APIError('user required', 400);
41
54
  const scopeId = scopeIdOf(data);
42
55
  const achievement = await resolveAchievement({
43
56
  req,
44
57
  data: data,
45
58
  });
46
59
  data.achievement = achievement.id;
47
- if (ruleGroupHasProgressRequirements(achievement.completionRules)) {
60
+ if (!system && ruleGroupHasProgressRequirements(achievement.completionRules)) {
48
61
  throw new APIError('This achievement is granted automatically when its completion rules pass.', 400);
49
62
  }
50
- const eligible = await evaluateRules({
51
- payload: req.payload,
52
- req,
53
- rules: achievement.eligibilityRules,
54
- userId: userId,
55
- scopeId,
56
- });
57
- if (!eligible) {
58
- throw new APIError('Eligibility rules not met for this achievement.', 400);
63
+ if (!system) {
64
+ const eligible = await evaluateRules({
65
+ payload: req.payload,
66
+ req,
67
+ rules: achievement.eligibilityRules,
68
+ userId,
69
+ scopeId,
70
+ });
71
+ if (!eligible) {
72
+ throw new APIError('Eligibility rules not met for this achievement.', 400);
73
+ }
74
+ }
75
+ if (system) {
76
+ data.status = data.status ?? 'pending';
77
+ }
78
+ else {
79
+ data.status = achievement.requiresReview === false ? 'approved' : 'pending';
59
80
  }
60
- data.status = achievement.requiresReview === false ? 'approved' : 'pending';
61
81
  return data;
62
82
  }
63
83
  if (operation === 'update') {
@@ -0,0 +1,4 @@
1
+ import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload';
2
+ export declare const afterGrantChange: CollectionAfterChangeHook;
3
+ export declare const afterGrantDelete: CollectionAfterDeleteHook;
4
+ //# sourceMappingURL=afterGrantMutation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"afterGrantMutation.d.ts","sourceRoot":"","sources":["../../../../src/collections/Grants/hooks/afterGrantMutation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,yBAAyB,EAAE,yBAAyB,EAAE,MAAM,SAAS,CAAA;AAyBnF,eAAO,MAAM,gBAAgB,EAAE,yBAmB9B,CAAA;AAED,eAAO,MAAM,gBAAgB,EAAE,yBAmB9B,CAAA"}
@@ -0,0 +1,56 @@
1
+ import { CTX_CASCADING_REVOKE, CTX_SKIP_GRANT_SIDE_EFFECTS, runAfterGrantCreated, runAfterGrantDeleted } from '../../../services/achievement/grantSideEffects';
2
+ import { relationId } from '../../../services/achievement/relationId';
3
+ import { collectionOf } from '../../../collections/helpers';
4
+ async function achievementSlug(req, achievementId) {
5
+ try {
6
+ const def = (await req.payload.findByID({
7
+ collection: collectionOf('achievements'),
8
+ id: achievementId,
9
+ depth: 0,
10
+ overrideAccess: true,
11
+ req,
12
+ select: { slug: true },
13
+ }));
14
+ return def?.slug;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ export const afterGrantChange = async ({ doc, operation, req }) => {
21
+ if (operation !== 'create')
22
+ return doc;
23
+ if (req.context?.[CTX_SKIP_GRANT_SIDE_EFFECTS])
24
+ return doc;
25
+ const userId = relationId(doc.user);
26
+ const achievementId = relationId(doc.achievement);
27
+ if (!userId || !achievementId)
28
+ return doc;
29
+ const scopeId = relationId(doc.scope);
30
+ await runAfterGrantCreated({
31
+ req,
32
+ userId,
33
+ achievementId,
34
+ scopeId,
35
+ achievementSlug: await achievementSlug(req, achievementId),
36
+ });
37
+ return doc;
38
+ };
39
+ export const afterGrantDelete = async ({ doc, req }) => {
40
+ if (req.context?.[CTX_SKIP_GRANT_SIDE_EFFECTS])
41
+ return doc;
42
+ const userId = relationId(doc.user);
43
+ const achievementId = relationId(doc.achievement);
44
+ if (!userId || !achievementId)
45
+ return doc;
46
+ const scopeId = relationId(doc.scope);
47
+ await runAfterGrantDeleted({
48
+ req,
49
+ userId,
50
+ achievementId,
51
+ scopeId,
52
+ achievementSlug: await achievementSlug(req, achievementId),
53
+ cascading: Boolean(req.context?.[CTX_CASCADING_REVOKE]),
54
+ });
55
+ return doc;
56
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/collections/Grants/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAK/C,eAAO,MAAM,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAI3C,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/collections/Grants/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAM/C,eAAO,MAAM,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAM3C,CAAA"}
@@ -1,7 +1,10 @@
1
1
  import { ensureUniqueGrant } from './ensureUniqueGrant';
2
2
  import { fillGrantTitle, setGrantTitle } from './setGrantTitle';
3
+ import { afterGrantChange, afterGrantDelete } from './afterGrantMutation';
3
4
  export const hooks = {
4
5
  beforeValidate: [ensureUniqueGrant],
5
6
  beforeChange: [setGrantTitle],
6
7
  afterRead: [fillGrantTitle],
8
+ afterChange: [afterGrantChange],
9
+ afterDelete: [afterGrantDelete],
7
10
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Grants/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAS,MAAM,SAAS,CAAA;AAOtD,wBAAgB,qBAAqB,IAAI,gBAAgB,CAyDxD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Grants/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAS,MAAM,SAAS,CAAA;AAQtD,wBAAgB,qBAAqB,IAAI,gBAAgB,CA4ExD"}
@@ -1,8 +1,13 @@
1
1
  import { collectionAdmin, collectionOf, columnsWithOptionalScope, optionalFields, slugOf } from '../helpers';
2
2
  import { scopeField, userField } from '../fields';
3
+ import { getAchievementOptions } from '../../options-store';
3
4
  import { access } from './access';
4
5
  import { hooks } from './hooks';
5
6
  export function buildGrantsCollection() {
7
+ const reconcilePath = getAchievementOptions().endpoints?.reconcile;
8
+ const apiPath = typeof reconcilePath === 'string' && reconcilePath.length > 0
9
+ ? `/api${reconcilePath.startsWith('/') ? reconcilePath : `/${reconcilePath}`}`
10
+ : null;
6
11
  return {
7
12
  slug: slugOf('grants'),
8
13
  labels: {
@@ -15,7 +20,19 @@ export function buildGrantsCollection() {
15
20
  useAsTitle: 'title',
16
21
  defaultColumns: columnsWithOptionalScope(['title', 'user', 'completedAt', 'scope']),
17
22
  listSearchableFields: ['title'],
18
- description: 'What someone has earned — one row per grant. Bulk create works here.',
23
+ description: 'What someone has earned — one row per grant. Creating or deleting a grant updates logs and composed progression automatically.',
24
+ ...(apiPath
25
+ ? {
26
+ components: {
27
+ beforeListTable: [
28
+ {
29
+ path: '@dappermountain/payload-plugin-achievements/client#ReconcileProgressionButton',
30
+ clientProps: { reconcilePath: apiPath },
31
+ },
32
+ ],
33
+ },
34
+ }
35
+ : {}),
19
36
  }),
20
37
  fields: [
21
38
  {
@@ -20,7 +20,7 @@ export function buildLogsCollection() {
20
20
  admin: collectionAdmin({
21
21
  useAsTitle: 'id',
22
22
  defaultColumns: ['type', 'user', 'actor', 'metric', 'change', 'createdAt'],
23
- description: 'A running history of what happened — grants, score changes, tier moves, and anything else you log.',
23
+ description: 'Member activity history — grants, revokes, score changes, tier moves, and host custom events. Not a second grant store.',
24
24
  }),
25
25
  fields: [
26
26
  ...optionalFields(scopeField()),
@@ -0,0 +1,3 @@
1
+ import type { CollectionConfig } from 'payload';
2
+ export declare const access: NonNullable<CollectionConfig['access']>;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/collections/TierRequests/access/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAK/C,eAAO,MAAM,MAAM,EAAE,WAAW,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAK1D,CAAA"}
@@ -0,0 +1,8 @@
1
+ import { isAuthenticated, requireOne } from '../../../access/index';
2
+ import { isReviewer, isSelfUser } from '../../../access/roles/index';
3
+ export const access = {
4
+ read: requireOne(isReviewer('tierRequests'), isSelfUser('user')),
5
+ create: isAuthenticated,
6
+ update: requireOne(isReviewer('tierRequests')),
7
+ delete: requireOne(isReviewer('tierRequests')),
8
+ };
@@ -0,0 +1,3 @@
1
+ import type { CollectionConfig } from 'payload';
2
+ export declare const hooks: CollectionConfig['hooks'];
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/collections/TierRequests/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAI/C,eAAO,MAAM,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAG3C,CAAA"}
@@ -0,0 +1,5 @@
1
+ import { afterTierRequestChange, prepareTierRequest } from './prepareTierRequest';
2
+ export const hooks = {
3
+ beforeValidate: [prepareTierRequest],
4
+ afterChange: [afterTierRequestChange],
5
+ };
@@ -0,0 +1,8 @@
1
+ import type { CollectionAfterChangeHook, CollectionBeforeValidateHook } from 'payload';
2
+ /**
3
+ * System-created requests (reconcile / progression sync) may set user freely.
4
+ * Member creates are not used for tiers in v1 — reviewers/system own the flow.
5
+ */
6
+ export declare const prepareTierRequest: CollectionBeforeValidateHook;
7
+ export declare const afterTierRequestChange: CollectionAfterChangeHook;
8
+ //# sourceMappingURL=prepareTierRequest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prepareTierRequest.d.ts","sourceRoot":"","sources":["../../../../src/collections/TierRequests/hooks/prepareTierRequest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,yBAAyB,EACzB,4BAA4B,EAE7B,MAAM,SAAS,CAAA;AAYhB;;;GAGG;AACH,eAAO,MAAM,kBAAkB,EAAE,4BAyChC,CAAA;AAED,eAAO,MAAM,sBAAsB,EAAE,yBA0BpC,CAAA"}
@@ -0,0 +1,67 @@
1
+ import { APIError } from 'payload';
2
+ import { collectionOf } from '../../../collections/helpers';
3
+ import { CTX_SYSTEM_REQUEST } from '../../../services/achievement/contextFlags';
4
+ import { syncTierChangedLog } from '../../../services/achievement/recordEvent';
5
+ import { relationId } from '../../../services/achievement/relationId';
6
+ function scopeIdOf(data) {
7
+ return relationId(data?.scope);
8
+ }
9
+ /**
10
+ * System-created requests (reconcile / progression sync) may set user freely.
11
+ * Member creates are not used for tiers in v1 — reviewers/system own the flow.
12
+ */
13
+ export const prepareTierRequest = async ({ data, operation, req, originalDoc, }) => {
14
+ if (!data)
15
+ return data;
16
+ if (operation === 'create') {
17
+ if (!req.context?.[CTX_SYSTEM_REQUEST]) {
18
+ const userId = req.user?.id;
19
+ if (typeof userId !== 'string')
20
+ throw new APIError('Unauthorized', 401);
21
+ data.user = userId;
22
+ }
23
+ const tierId = relationId(data.tier);
24
+ if (!tierId)
25
+ throw new APIError('tier required', 400);
26
+ const tier = await req.payload.findByID({
27
+ collection: collectionOf('tiers'),
28
+ id: tierId,
29
+ depth: 0,
30
+ overrideAccess: true,
31
+ req,
32
+ });
33
+ if (!tier)
34
+ throw new APIError(`Unknown tier: ${tierId}`, 400);
35
+ if (tier.requiresReview !== true) {
36
+ throw new APIError('This tier does not require review.', 400);
37
+ }
38
+ data.tier = tierId;
39
+ if (!data.status)
40
+ data.status = 'pending';
41
+ return data;
42
+ }
43
+ if (operation === 'update' && originalDoc) {
44
+ data.user = originalDoc.user;
45
+ data.tier = originalDoc.tier;
46
+ }
47
+ return data;
48
+ };
49
+ export const afterTierRequestChange = async ({ doc, operation, previousDoc, req, }) => {
50
+ const status = doc.status;
51
+ if (status !== 'approved')
52
+ return doc;
53
+ const previousStatus = operation === 'update' ? previousDoc?.status : undefined;
54
+ if (previousStatus === 'approved')
55
+ return doc;
56
+ const userId = relationId(doc.user);
57
+ if (!userId)
58
+ return doc;
59
+ const scopeId = scopeIdOf(doc);
60
+ await syncTierChangedLog({
61
+ payload: req.payload,
62
+ req: req,
63
+ userId,
64
+ scopeId,
65
+ });
66
+ return doc;
67
+ };
@@ -0,0 +1,3 @@
1
+ import type { CollectionConfig } from 'payload';
2
+ export declare function buildTierRequestsCollection(): CollectionConfig;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/TierRequests/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAS,MAAM,SAAS,CAAA;AAOtD,wBAAgB,2BAA2B,IAAI,gBAAgB,CA2C9D"}
@@ -0,0 +1,47 @@
1
+ import { collectionAdmin, collectionOf, optionalFields, slugOf } from '../helpers';
2
+ import { scopeField, userField } from '../fields';
3
+ import { access } from './access';
4
+ import { hooks } from './hooks';
5
+ export function buildTierRequestsCollection() {
6
+ return {
7
+ slug: slugOf('tierRequests'),
8
+ labels: {
9
+ singular: 'Tier Request',
10
+ plural: 'Tier Requests',
11
+ },
12
+ access: { ...access },
13
+ hooks,
14
+ admin: collectionAdmin({
15
+ useAsTitle: 'id',
16
+ defaultColumns: ['user', 'tier', 'status', 'createdAt'],
17
+ description: 'When a member has met a tier’s unlock rules but that tier requires review before it becomes their current tier.',
18
+ }),
19
+ fields: [
20
+ ...optionalFields(scopeField()),
21
+ userField(),
22
+ {
23
+ name: 'tier',
24
+ type: 'relationship',
25
+ relationTo: collectionOf('tiers'),
26
+ required: true,
27
+ index: true,
28
+ },
29
+ {
30
+ name: 'status',
31
+ type: 'select',
32
+ required: true,
33
+ defaultValue: 'pending',
34
+ options: [
35
+ { label: 'Pending', value: 'pending' },
36
+ { label: 'Approved', value: 'approved' },
37
+ { label: 'Rejected', value: 'rejected' },
38
+ ],
39
+ },
40
+ {
41
+ name: 'note',
42
+ type: 'textarea',
43
+ admin: { description: 'Optional note from the reviewer.' },
44
+ },
45
+ ],
46
+ };
47
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Tiers/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAQ/C,wBAAgB,oBAAoB,IAAI,gBAAgB,CA+EvD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Tiers/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAQ/C,wBAAgB,oBAAoB,IAAI,gBAAgB,CAwFvD"}
@@ -11,8 +11,8 @@ export function buildTiersCollection() {
11
11
  hooks,
12
12
  admin: collectionAdmin({
13
13
  useAsTitle: 'name',
14
- defaultColumns: columnsWithOptionalScope(['name', 'slug', 'rank', 'scope']),
15
- description: 'Unlock rules decide when someone reaches this step. Current tier is derived by walking the ladder (lowest rank first) — there is no stored progress row.',
14
+ defaultColumns: columnsWithOptionalScope(['name', 'slug', 'rank', 'requiresReview', 'scope']),
15
+ description: 'Unlock rules decide when someone reaches this step. Current tier is derived by walking the ladder (lowest rank first). Tiers with Requires review also need an approved tier request.',
16
16
  }),
17
17
  fields: [
18
18
  ...optionalFields(scopeField()),
@@ -67,12 +67,20 @@ export function buildTiersCollection() {
67
67
  : []),
68
68
  ],
69
69
  },
70
+ {
71
+ name: 'requiresReview',
72
+ type: 'checkbox',
73
+ defaultValue: false,
74
+ admin: {
75
+ description: 'When enabled, unlock rules alone are not enough — an approved tier request is required before this step becomes the member’s current tier.',
76
+ },
77
+ },
70
78
  {
71
79
  name: 'unlockRules',
72
80
  type: 'group',
73
81
  label: 'Unlock rules',
74
82
  admin: {
75
- description: 'Rules that must pass to unlock this tier (all-of or any-of). Empty rules mean this step is open. The plugin derives current tier by walking ranks; optional tier.changed logs record moves.',
83
+ description: 'Rules that must pass to unlock this tier (all-of or any-of). Empty rules mean this step is open. Current tier is derived by walking ranks; optional tier.changed logs record moves.',
76
84
  },
77
85
  fields: buildRuleTreeFields(),
78
86
  },
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/collections/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAuB/C,qEAAqE;AACrE,wBAAgB,2BAA2B,IAAI,gBAAgB,EAAE,CAMhE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/collections/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAyB/C,qEAAqE;AACrE,wBAAgB,2BAA2B,IAAI,gBAAgB,EAAE,CAMhE"}
@@ -6,6 +6,7 @@ import { buildEventTypesCollection } from './EventTypes/index';
6
6
  import { buildGrantsCollection } from './Grants/index';
7
7
  import { buildLogsCollection } from './Logs/index';
8
8
  import { buildMetricsCollection } from './Metrics/index';
9
+ import { buildTierRequestsCollection } from './TierRequests/index';
9
10
  import { buildTiersCollection } from './Tiers/index';
10
11
  const builders = {
11
12
  metrics: buildMetricsCollection,
@@ -14,6 +15,7 @@ const builders = {
14
15
  achievements: buildAchievementsCollection,
15
16
  grants: buildGrantsCollection,
16
17
  achievementRequests: buildAchievementRequestsCollection,
18
+ tierRequests: buildTierRequestsCollection,
17
19
  logs: buildLogsCollection,
18
20
  };
19
21
  /** Build achievement collections after plugin options are stored. */
@@ -1 +1 @@
1
- {"version":3,"file":"slugs.d.ts","sourceRoot":"","sources":["../../src/collections/slugs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,wBAAwB,EAAE,MAAM,UAAU,CAAA;AAElF;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,gBAAgB,CAAA;AAEtD;;;GAGG;AACH,eAAO,MAAM,6BAA6B,EAAE,MAAM,CAAC,wBAAwB,EAAE,MAAM,CAQlF,CAAA;AAED,eAAO,MAAM,mBAAmB,iBAAiB,CAAA;AAEjD;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,GAAE,wBAA6B,GACrC,MAAM,CAAC,wBAAwB,EAAE,MAAM,CAAC,CAkB1C;AAED,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,wBAA6B,GAAG,MAAM,GAAG,KAAK,CAMxF"}
1
+ {"version":3,"file":"slugs.d.ts","sourceRoot":"","sources":["../../src/collections/slugs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,wBAAwB,EAAE,MAAM,UAAU,CAAA;AAElF;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,gBAAgB,CAAA;AAEtD;;;GAGG;AACH,eAAO,MAAM,6BAA6B,EAAE,MAAM,CAAC,wBAAwB,EAAE,MAAM,CASlF,CAAA;AAED,eAAO,MAAM,mBAAmB,iBAAiB,CAAA;AAEjD;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,GAAE,wBAA6B,GACrC,MAAM,CAAC,wBAAwB,EAAE,MAAM,CAAC,CAkB1C;AAED,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,wBAA6B,GAAG,MAAM,GAAG,KAAK,CAMxF"}
@@ -14,6 +14,7 @@ export const DEFAULT_COLLECTION_BASE_SLUGS = {
14
14
  achievements: 'definitions',
15
15
  grants: 'grants',
16
16
  achievementRequests: 'requests',
17
+ tierRequests: 'tier-requests',
17
18
  logs: 'logs',
18
19
  eventTypes: 'event-types',
19
20
  metrics: 'metrics',