@dappermountain/payload-plugin-achievements 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +36 -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/cli/index.d.ts +3 -0
  9. package/dist/cli/index.d.ts.map +1 -0
  10. package/dist/cli/index.js +1 -0
  11. package/dist/cli/reconcile.d.ts +18 -0
  12. package/dist/cli/reconcile.d.ts.map +1 -0
  13. package/dist/cli/reconcile.js +90 -0
  14. package/dist/collections/AchievementRequests/hooks/prepareAchievementRequest.d.ts.map +1 -1
  15. package/dist/collections/AchievementRequests/hooks/prepareAchievementRequest.js +35 -15
  16. package/dist/collections/Achievements/hooks/afterRequiresReviewDisabled.d.ts +7 -0
  17. package/dist/collections/Achievements/hooks/afterRequiresReviewDisabled.d.ts.map +1 -0
  18. package/dist/collections/Achievements/hooks/afterRequiresReviewDisabled.js +15 -0
  19. package/dist/collections/Achievements/hooks/index.d.ts.map +1 -1
  20. package/dist/collections/Achievements/hooks/index.js +2 -0
  21. package/dist/collections/Achievements/index.d.ts.map +1 -1
  22. package/dist/collections/Achievements/index.js +3 -0
  23. package/dist/collections/Grants/hooks/afterGrantMutation.d.ts +4 -0
  24. package/dist/collections/Grants/hooks/afterGrantMutation.d.ts.map +1 -0
  25. package/dist/collections/Grants/hooks/afterGrantMutation.js +56 -0
  26. package/dist/collections/Grants/hooks/index.d.ts.map +1 -1
  27. package/dist/collections/Grants/hooks/index.js +3 -0
  28. package/dist/collections/Grants/index.d.ts.map +1 -1
  29. package/dist/collections/Grants/index.js +18 -1
  30. package/dist/collections/Logs/index.js +1 -1
  31. package/dist/collections/TierRequests/access/index.d.ts +3 -0
  32. package/dist/collections/TierRequests/access/index.d.ts.map +1 -0
  33. package/dist/collections/TierRequests/access/index.js +8 -0
  34. package/dist/collections/TierRequests/hooks/index.d.ts +3 -0
  35. package/dist/collections/TierRequests/hooks/index.d.ts.map +1 -0
  36. package/dist/collections/TierRequests/hooks/index.js +5 -0
  37. package/dist/collections/TierRequests/hooks/prepareTierRequest.d.ts +8 -0
  38. package/dist/collections/TierRequests/hooks/prepareTierRequest.d.ts.map +1 -0
  39. package/dist/collections/TierRequests/hooks/prepareTierRequest.js +67 -0
  40. package/dist/collections/TierRequests/index.d.ts +3 -0
  41. package/dist/collections/TierRequests/index.d.ts.map +1 -0
  42. package/dist/collections/TierRequests/index.js +47 -0
  43. package/dist/collections/Tiers/hooks/afterRequiresReviewDisabled.d.ts +7 -0
  44. package/dist/collections/Tiers/hooks/afterRequiresReviewDisabled.d.ts.map +1 -0
  45. package/dist/collections/Tiers/hooks/afterRequiresReviewDisabled.js +15 -0
  46. package/dist/collections/Tiers/hooks/index.d.ts.map +1 -1
  47. package/dist/collections/Tiers/hooks/index.js +2 -0
  48. package/dist/collections/Tiers/index.d.ts.map +1 -1
  49. package/dist/collections/Tiers/index.js +11 -3
  50. package/dist/collections/index.d.ts.map +1 -1
  51. package/dist/collections/index.js +2 -0
  52. package/dist/collections/slugs.d.ts.map +1 -1
  53. package/dist/collections/slugs.js +1 -0
  54. package/dist/defaults.d.ts +3 -0
  55. package/dist/defaults.d.ts.map +1 -1
  56. package/dist/defaults.js +12 -3
  57. package/dist/endpoints/index.d.ts.map +1 -1
  58. package/dist/endpoints/index.js +9 -3
  59. package/dist/endpoints/reconcile.d.ts +8 -0
  60. package/dist/endpoints/reconcile.d.ts.map +1 -0
  61. package/dist/endpoints/reconcile.js +73 -0
  62. package/dist/exports/client.d.ts +1 -0
  63. package/dist/exports/client.d.ts.map +1 -1
  64. package/dist/exports/client.js +1 -0
  65. package/dist/index.d.ts +3 -3
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +2 -2
  68. package/dist/seed/index.d.ts +2 -0
  69. package/dist/seed/index.d.ts.map +1 -1
  70. package/dist/seed/index.js +1 -0
  71. package/dist/services/achievement/contextFlags.d.ts +5 -0
  72. package/dist/services/achievement/contextFlags.d.ts.map +1 -0
  73. package/dist/services/achievement/contextFlags.js +4 -0
  74. package/dist/services/achievement/grantSideEffects.d.ts +54 -0
  75. package/dist/services/achievement/grantSideEffects.d.ts.map +1 -0
  76. package/dist/services/achievement/grantSideEffects.js +269 -0
  77. package/dist/services/achievement/index.d.ts +6 -0
  78. package/dist/services/achievement/index.d.ts.map +1 -1
  79. package/dist/services/achievement/index.js +4 -0
  80. package/dist/services/achievement/reconcile.d.ts +36 -0
  81. package/dist/services/achievement/reconcile.d.ts.map +1 -0
  82. package/dist/services/achievement/reconcile.js +292 -0
  83. package/dist/services/achievement/recordEvent.d.ts.map +1 -1
  84. package/dist/services/achievement/recordEvent.js +8 -0
  85. package/dist/services/achievement/releaseReviewGate.d.ts +23 -0
  86. package/dist/services/achievement/releaseReviewGate.d.ts.map +1 -0
  87. package/dist/services/achievement/releaseReviewGate.js +71 -0
  88. package/dist/services/achievement/resolveCurrentTier.d.ts +1 -1
  89. package/dist/services/achievement/resolveCurrentTier.d.ts.map +1 -1
  90. package/dist/services/achievement/resolveCurrentTier.js +12 -2
  91. package/dist/services/achievement/submitAchievementRequest.d.ts +4 -2
  92. package/dist/services/achievement/submitAchievementRequest.d.ts.map +1 -1
  93. package/dist/services/achievement/submitAchievementRequest.js +8 -101
  94. package/dist/services/achievement/tierApproval.d.ts +8 -0
  95. package/dist/services/achievement/tierApproval.d.ts.map +1 -0
  96. package/dist/services/achievement/tierApproval.js +21 -0
  97. package/dist/services/achievement/tierProgression.d.ts +22 -0
  98. package/dist/services/achievement/tierProgression.d.ts.map +1 -0
  99. package/dist/services/achievement/tierProgression.js +100 -0
  100. package/dist/types.d.ts +8 -2
  101. package/dist/types.d.ts.map +1 -1
  102. package/package.json +11 -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,42 @@ 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 })
264
+
265
+ // Optional filters (ids or slugs)
266
+ await reconcileProgression({
267
+ payload,
268
+ userId: '…',
269
+ scopeId: '…',
270
+ achievementSlug: 'ring-grim',
271
+ tierSlug: 'ring-solid',
272
+ })
273
+
274
+ // Host CLI (only boot config in the app — flags live in the plugin):
275
+ // bun --env-file=.env ./scripts/reconcile-achievements.ts -- --user=<id> --achievement=ring-grim
276
+ import { runReconcileCli } from '@dappermountain/payload-plugin-achievements/cli'
277
+ await runReconcileCli({ config })
254
278
  ```
255
279
 
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.
280
+ 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`.
281
+
282
+ 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).
283
+
284
+ **Admin:** Grants list includes **Repair progression**, which `POST`s the reconcile endpoint (requires `canReview`).
257
285
 
258
286
  ## REST surface
259
287
 
260
288
  | Goal | Request |
261
289
  | --- | --- |
262
290
  | Current user snapshot | `GET /api/achievements/me?scope=<scopeId>&limit=10&page=1` |
291
+ | Repair progression | `POST /api/achievements/reconcile` (reviewer; optional `{ userId, scopeId, achievementId\|achievementSlug, tierId\|tierSlug, limit }`) |
263
292
  | List grants | `GET /api/achievement-grants?where[user][equals]=<userId>` |
264
293
  | Log history | `GET /api/achievement-logs?where[user][equals]=<userId>` |
265
294
  | Request an achievement | `POST /api/achievement-requests` with `{ achievement }` |
266
- | Approve / reject | `PATCH /api/achievement-requests/:id` with `{ status: "approved" \| "rejected" }` |
295
+ | Approve / reject achievement | `PATCH /api/achievement-requests/:id` with `{ status: "approved" \| "rejected" }` |
296
+ | Approve / reject tier | `PATCH /api/achievement-tier-requests/:id` with `{ status: "approved" \| "rejected" }` |
267
297
  | Browse catalogs | `GET /api/achievement-event-types`, `GET /api/achievement-metrics`, … |
268
298
 
269
299
  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
  ];
@@ -0,0 +1,3 @@
1
+ export { parseReconcileCliArgs, printReconcileCliHelp, runReconcileCli, } from './reconcile';
2
+ export type { ReconcileCliFilters } from './reconcile';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,eAAe,GAChB,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA"}
@@ -0,0 +1 @@
1
+ export { parseReconcileCliArgs, printReconcileCliHelp, runReconcileCli, } from './reconcile';
@@ -0,0 +1,18 @@
1
+ import { type SanitizedConfig } from 'payload';
2
+ import { type ReconcileProgressionArgs, type ReconcileProgressionResult } from '../services/achievement/reconcile';
3
+ export type ReconcileCliFilters = Omit<ReconcileProgressionArgs, 'payload' | 'req'>;
4
+ /** Parse `--user`, `--achievement`, `--tier`, `--scope`, `--limit` (ids or slugs). */
5
+ export declare function parseReconcileCliArgs(argv?: string[]): ReconcileCliFilters;
6
+ export declare function printReconcileCliHelp(): void;
7
+ /**
8
+ * Boot Payload from the host config and repair progression.
9
+ * Host scripts should only import config and call this (filters via argv or `filters`).
10
+ */
11
+ export declare function runReconcileCli(args: {
12
+ config: SanitizedConfig | Promise<SanitizedConfig>;
13
+ argv?: string[];
14
+ filters?: ReconcileCliFilters;
15
+ /** When false, return the result instead of exiting the process. Default true. */
16
+ exit?: boolean;
17
+ }): Promise<ReconcileProgressionResult>;
18
+ //# sourceMappingURL=reconcile.d.ts.map
@@ -0,0 +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,mCAAmC,CAAA;AAE1C,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,CA2BtC"}
@@ -0,0 +1,90 @@
1
+ import { getPayload } from 'payload';
2
+ import { reconcileProgression, } from '../services/achievement/reconcile';
3
+ function readFlag(argv, names) {
4
+ for (const name of names) {
5
+ const prefix = `${name}=`;
6
+ for (const arg of argv) {
7
+ if (arg === name) {
8
+ const idx = argv.indexOf(arg);
9
+ const next = argv[idx + 1];
10
+ if (next && !next.startsWith('-'))
11
+ return next;
12
+ }
13
+ if (arg.startsWith(prefix)) {
14
+ const value = arg.slice(prefix.length);
15
+ if (value)
16
+ return value;
17
+ }
18
+ }
19
+ }
20
+ return undefined;
21
+ }
22
+ function readLimit(argv) {
23
+ const raw = readFlag(argv, ['--limit', '-n']);
24
+ if (!raw)
25
+ return undefined;
26
+ const n = Number(raw);
27
+ return Number.isFinite(n) ? Math.floor(n) : undefined;
28
+ }
29
+ /** Parse `--user`, `--achievement`, `--tier`, `--scope`, `--limit` (ids or slugs). */
30
+ export function parseReconcileCliArgs(argv = process.argv.slice(2)) {
31
+ return {
32
+ userId: readFlag(argv, ['--user', '--userId']),
33
+ scopeId: readFlag(argv, ['--scope', '--scopeId']) ?? null,
34
+ achievementId: readFlag(argv, ['--achievementId']),
35
+ achievementSlug: readFlag(argv, ['--achievement', '--achievementSlug']),
36
+ tierId: readFlag(argv, ['--tierId']),
37
+ tierSlug: readFlag(argv, ['--tier', '--tierSlug']),
38
+ limit: readLimit(argv),
39
+ };
40
+ }
41
+ export function printReconcileCliHelp() {
42
+ // eslint-disable-next-line no-console -- CLI help
43
+ console.log(`Usage: achievements reconcile [options]
44
+
45
+ Options:
46
+ --user, --userId <id>
47
+ --scope, --scopeId <id>
48
+ --achievement, --achievementSlug <slug>
49
+ --achievementId <id>
50
+ --tier, --tierSlug <slug>
51
+ --tierId <id>
52
+ --limit, -n <number>
53
+ --help, -h
54
+ `);
55
+ }
56
+ /**
57
+ * Boot Payload from the host config and repair progression.
58
+ * Host scripts should only import config and call this (filters via argv or `filters`).
59
+ */
60
+ export async function runReconcileCli(args) {
61
+ const argv = args.argv ?? process.argv.slice(2);
62
+ if (argv.includes('--help') || argv.includes('-h')) {
63
+ printReconcileCliHelp();
64
+ if (args.exit !== false)
65
+ process.exit(0);
66
+ return {
67
+ usersScanned: 0,
68
+ grantedLogsBackfilled: 0,
69
+ compositesGranted: 0,
70
+ achievementRequestsEnsured: 0,
71
+ tierRequestsEnsured: 0,
72
+ tierLogsWritten: 0,
73
+ };
74
+ }
75
+ const filters = args.filters ?? parseReconcileCliArgs(argv);
76
+ const payload = await getPayload({ config: args.config });
77
+ try {
78
+ const result = await reconcileProgression({ payload, ...filters });
79
+ payload.logger.info({ msg: 'achievements reconcile complete', ...filters, ...result });
80
+ if (args.exit !== false)
81
+ process.exit(0);
82
+ return result;
83
+ }
84
+ catch (error) {
85
+ payload.logger.error({ err: error, msg: 'achievements reconcile failed' });
86
+ if (args.exit !== false)
87
+ process.exit(1);
88
+ throw error;
89
+ }
90
+ }
@@ -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,7 @@
1
+ import type { CollectionAfterChangeHook } from 'payload';
2
+ /**
3
+ * When Requires review is turned off, approve pending requests for this achievement
4
+ * so each waiting user is granted via existing request hooks.
5
+ */
6
+ export declare const afterRequiresReviewDisabled: CollectionAfterChangeHook;
7
+ //# sourceMappingURL=afterRequiresReviewDisabled.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"afterRequiresReviewDisabled.d.ts","sourceRoot":"","sources":["../../../../src/collections/Achievements/hooks/afterRequiresReviewDisabled.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,SAAS,CAAA;AAOxD;;;GAGG;AACH,eAAO,MAAM,2BAA2B,EAAE,yBAmBzC,CAAA"}
@@ -0,0 +1,15 @@
1
+ import { didDisableRequiresReview, releasePendingAchievementReviews, } from '../../../services/achievement/releaseReviewGate';
2
+ /**
3
+ * When Requires review is turned off, approve pending requests for this achievement
4
+ * so each waiting user is granted via existing request hooks.
5
+ */
6
+ export const afterRequiresReviewDisabled = async ({ doc, operation, previousDoc, req, }) => {
7
+ if (operation !== 'update')
8
+ return doc;
9
+ if (!didDisableRequiresReview(previousDoc, doc)) {
10
+ return doc;
11
+ }
12
+ const achievementId = String(doc.id);
13
+ await releasePendingAchievementReviews({ req, achievementId });
14
+ return doc;
15
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/collections/Achievements/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAI/C,eAAO,MAAM,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAE3C,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/collections/Achievements/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAK/C,eAAO,MAAM,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAG3C,CAAA"}
@@ -1,4 +1,6 @@
1
+ import { afterRequiresReviewDisabled } from './afterRequiresReviewDisabled';
1
2
  import { validateAchievementRules } from './validateEligibilityRules';
2
3
  export const hooks = {
3
4
  beforeValidate: [validateAchievementRules],
5
+ afterChange: [afterRequiresReviewDisabled],
4
6
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Achievements/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAO/C,wBAAgB,2BAA2B,IAAI,gBAAgB,CA0D9D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Achievements/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAO/C,wBAAgB,2BAA2B,IAAI,gBAAgB,CA8D9D"}
@@ -38,6 +38,9 @@ export function buildAchievementsCollection() {
38
38
  type: 'checkbox',
39
39
  defaultValue: true,
40
40
  label: 'Requires review',
41
+ admin: {
42
+ description: 'When turned off, any pending requests for this achievement are approved automatically (one user at a time via existing grant hooks).',
43
+ },
41
44
  },
42
45
  {
43
46
  name: 'eligibilityRules',
@@ -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"}