@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.
- package/README.md +20 -6
- package/dist/admin/ReconcileProgressionButton.d.ts +11 -0
- package/dist/admin/ReconcileProgressionButton.d.ts.map +1 -0
- package/dist/admin/ReconcileProgressionButton.js +41 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +1 -0
- package/dist/collections/AchievementRequests/hooks/prepareAchievementRequest.d.ts.map +1 -1
- package/dist/collections/AchievementRequests/hooks/prepareAchievementRequest.js +35 -15
- package/dist/collections/Grants/hooks/afterGrantMutation.d.ts +4 -0
- package/dist/collections/Grants/hooks/afterGrantMutation.d.ts.map +1 -0
- package/dist/collections/Grants/hooks/afterGrantMutation.js +56 -0
- package/dist/collections/Grants/hooks/index.d.ts.map +1 -1
- package/dist/collections/Grants/hooks/index.js +3 -0
- package/dist/collections/Grants/index.d.ts.map +1 -1
- package/dist/collections/Grants/index.js +18 -1
- package/dist/collections/Logs/index.js +1 -1
- package/dist/collections/TierRequests/access/index.d.ts +3 -0
- package/dist/collections/TierRequests/access/index.d.ts.map +1 -0
- package/dist/collections/TierRequests/access/index.js +8 -0
- package/dist/collections/TierRequests/hooks/index.d.ts +3 -0
- package/dist/collections/TierRequests/hooks/index.d.ts.map +1 -0
- package/dist/collections/TierRequests/hooks/index.js +5 -0
- package/dist/collections/TierRequests/hooks/prepareTierRequest.d.ts +8 -0
- package/dist/collections/TierRequests/hooks/prepareTierRequest.d.ts.map +1 -0
- package/dist/collections/TierRequests/hooks/prepareTierRequest.js +67 -0
- package/dist/collections/TierRequests/index.d.ts +3 -0
- package/dist/collections/TierRequests/index.d.ts.map +1 -0
- package/dist/collections/TierRequests/index.js +47 -0
- package/dist/collections/Tiers/index.d.ts.map +1 -1
- package/dist/collections/Tiers/index.js +11 -3
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/collections/index.js +2 -0
- package/dist/collections/slugs.d.ts.map +1 -1
- package/dist/collections/slugs.js +1 -0
- package/dist/defaults.d.ts +3 -0
- package/dist/defaults.d.ts.map +1 -1
- package/dist/defaults.js +12 -3
- package/dist/endpoints/index.d.ts.map +1 -1
- package/dist/endpoints/index.js +9 -3
- package/dist/endpoints/reconcile.d.ts +8 -0
- package/dist/endpoints/reconcile.d.ts.map +1 -0
- package/dist/endpoints/reconcile.js +70 -0
- package/dist/exports/client.d.ts +1 -0
- package/dist/exports/client.d.ts.map +1 -1
- package/dist/exports/client.js +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/seed/index.d.ts +2 -0
- package/dist/seed/index.d.ts.map +1 -1
- package/dist/seed/index.js +1 -0
- package/dist/services/achievement/contextFlags.d.ts +5 -0
- package/dist/services/achievement/contextFlags.d.ts.map +1 -0
- package/dist/services/achievement/contextFlags.js +4 -0
- package/dist/services/achievement/grantSideEffects.d.ts +54 -0
- package/dist/services/achievement/grantSideEffects.d.ts.map +1 -0
- package/dist/services/achievement/grantSideEffects.js +269 -0
- package/dist/services/achievement/index.d.ts +4 -0
- package/dist/services/achievement/index.d.ts.map +1 -1
- package/dist/services/achievement/index.js +3 -0
- package/dist/services/achievement/reconcile.d.ts +24 -0
- package/dist/services/achievement/reconcile.d.ts.map +1 -0
- package/dist/services/achievement/reconcile.js +170 -0
- package/dist/services/achievement/recordEvent.d.ts.map +1 -1
- package/dist/services/achievement/recordEvent.js +8 -0
- package/dist/services/achievement/resolveCurrentTier.d.ts +1 -1
- package/dist/services/achievement/resolveCurrentTier.d.ts.map +1 -1
- package/dist/services/achievement/resolveCurrentTier.js +12 -2
- package/dist/services/achievement/submitAchievementRequest.d.ts +4 -2
- package/dist/services/achievement/submitAchievementRequest.d.ts.map +1 -1
- package/dist/services/achievement/submitAchievementRequest.js +8 -101
- package/dist/services/achievement/tierApproval.d.ts +8 -0
- package/dist/services/achievement/tierApproval.d.ts.map +1 -0
- package/dist/services/achievement/tierApproval.js +21 -0
- package/dist/services/achievement/tierProgression.d.ts +22 -0
- package/dist/services/achievement/tierProgression.d.ts.map +1 -0
- package/dist/services/achievement/tierProgression.js +100 -0
- package/dist/types.d.ts +8 -2
- package/dist/types.d.ts.map +1 -1
- 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
|
-
|
|
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
|
-
| `
|
|
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.
|
|
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";
|
package/dist/catalog.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB
|
|
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;
|
|
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
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 @@
|
|
|
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;
|
|
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;
|
|
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.
|
|
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: '
|
|
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 @@
|
|
|
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 @@
|
|
|
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,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 @@
|
|
|
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,
|
|
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)
|
|
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.
|
|
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;
|
|
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,
|
|
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"}
|