@dappermountain/payload-plugin-achievements 0.2.0 → 0.2.2

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 (45) hide show
  1. package/README.md +29 -1
  2. package/dist/cli/index.d.ts +3 -0
  3. package/dist/cli/index.d.ts.map +1 -0
  4. package/dist/cli/index.js +1 -0
  5. package/dist/cli/reconcile.d.ts +18 -0
  6. package/dist/cli/reconcile.d.ts.map +1 -0
  7. package/dist/cli/reconcile.js +90 -0
  8. package/dist/collections/Achievements/hooks/afterRequiresReviewDisabled.d.ts +7 -0
  9. package/dist/collections/Achievements/hooks/afterRequiresReviewDisabled.d.ts.map +1 -0
  10. package/dist/collections/Achievements/hooks/afterRequiresReviewDisabled.js +15 -0
  11. package/dist/collections/Achievements/hooks/index.d.ts.map +1 -1
  12. package/dist/collections/Achievements/hooks/index.js +2 -0
  13. package/dist/collections/Achievements/index.d.ts.map +1 -1
  14. package/dist/collections/Achievements/index.js +3 -0
  15. package/dist/collections/Logs/index.d.ts.map +1 -1
  16. package/dist/collections/Logs/index.js +3 -1
  17. package/dist/collections/Tiers/hooks/afterRequiresReviewDisabled.d.ts +7 -0
  18. package/dist/collections/Tiers/hooks/afterRequiresReviewDisabled.d.ts.map +1 -0
  19. package/dist/collections/Tiers/hooks/afterRequiresReviewDisabled.js +15 -0
  20. package/dist/collections/Tiers/hooks/index.d.ts.map +1 -1
  21. package/dist/collections/Tiers/hooks/index.js +2 -0
  22. package/dist/collections/Tiers/index.js +1 -1
  23. package/dist/endpoints/reconcile.d.ts +2 -2
  24. package/dist/endpoints/reconcile.d.ts.map +1 -1
  25. package/dist/endpoints/reconcile.js +23 -20
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/services/achievement/grantSideEffects.d.ts.map +1 -1
  30. package/dist/services/achievement/grantSideEffects.js +9 -8
  31. package/dist/services/achievement/index.d.ts +5 -1
  32. package/dist/services/achievement/index.d.ts.map +1 -1
  33. package/dist/services/achievement/index.js +2 -0
  34. package/dist/services/achievement/logData.d.ts +31 -0
  35. package/dist/services/achievement/logData.d.ts.map +1 -0
  36. package/dist/services/achievement/logData.js +30 -0
  37. package/dist/services/achievement/reconcile.d.ts +19 -7
  38. package/dist/services/achievement/reconcile.d.ts.map +1 -1
  39. package/dist/services/achievement/reconcile.js +151 -26
  40. package/dist/services/achievement/recordEvent.d.ts.map +1 -1
  41. package/dist/services/achievement/recordEvent.js +20 -7
  42. package/dist/services/achievement/releaseReviewGate.d.ts +23 -0
  43. package/dist/services/achievement/releaseReviewGate.d.ts.map +1 -0
  44. package/dist/services/achievement/releaseReviewGate.js +71 -0
  45. package/package.json +6 -1
package/README.md CHANGED
@@ -261,10 +261,38 @@ const tier = await resolveCurrentTier({ payload, userId, scopeId })
261
261
 
262
262
  // Repair users whose grants predate side-effect hooks (idempotent)
263
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 })
264
278
  ```
265
279
 
266
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`.
267
281
 
282
+ Engine log `data` for those system events uses a shared transition envelope:
283
+
284
+ ```ts
285
+ { from: { id, slug? } | null, to: { id, slug? } | null }
286
+ ```
287
+
288
+ | type | `from` | `to` |
289
+ | --- | --- | --- |
290
+ | `tier.changed` | previous tier (null on first climb) | current tier |
291
+ | `achievement.granted` | `null` | achievement |
292
+ | `achievement.revoked` | achievement | `null` |
293
+
294
+ 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).
295
+
268
296
  **Admin:** Grants list includes **Repair progression**, which `POST`s the reconcile endpoint (requires `canReview`).
269
297
 
270
298
  ## REST surface
@@ -272,7 +300,7 @@ Pass catalog **slugs** (or ids) for `type` and `metric`. Metric totals are the s
272
300
  | Goal | Request |
273
301
  | --- | --- |
274
302
  | 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 }`) |
303
+ | Repair progression | `POST /api/achievements/reconcile` (reviewer; optional `{ userId, scopeId, achievementId\|achievementSlug, tierId\|tierSlug, limit }`) |
276
304
  | List grants | `GET /api/achievement-grants?where[user][equals]=<userId>` |
277
305
  | Log history | `GET /api/achievement-logs?where[user][equals]=<userId>` |
278
306
  | Request an achievement | `POST /api/achievement-requests` with `{ achievement }` |
@@ -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
+ }
@@ -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',
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Logs/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAS,MAAM,SAAS,CAAA;AAUtD,+EAA+E;AAC/E,wBAAgB,mBAAmB,IAAI,gBAAgB,CA0GtD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Logs/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAS,MAAM,SAAS,CAAA;AAUtD,+EAA+E;AAC/E,wBAAgB,mBAAmB,IAAI,gBAAgB,CA6GtD"}
@@ -104,7 +104,9 @@ export function buildLogsCollection() {
104
104
  {
105
105
  name: 'data',
106
106
  type: 'json',
107
- admin: { description: 'Any extra structured details.' },
107
+ admin: {
108
+ description: 'Engine transition envelope: `{ from, to }` each `{ id, slug? }` or null (tier.changed, achievement.granted/revoked). Hosts may add extra keys.',
109
+ },
108
110
  },
109
111
  ],
110
112
  },
@@ -0,0 +1,7 @@
1
+ import type { CollectionAfterChangeHook } from 'payload';
2
+ /**
3
+ * When Requires review is turned off, approve pending tier requests for this step
4
+ * so each waiting user gets a tier.changed audit 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/Tiers/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, releasePendingTierReviews, } from '../../../services/achievement/releaseReviewGate';
2
+ /**
3
+ * When Requires review is turned off, approve pending tier requests for this step
4
+ * so each waiting user gets a tier.changed audit 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 tierId = String(doc.id);
13
+ await releasePendingTierReviews({ req, tierId });
14
+ return doc;
15
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/collections/Tiers/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/Tiers/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 { validateUnlockRules } from './validateUnlockRules';
2
3
  export const hooks = {
3
4
  beforeValidate: [validateUnlockRules],
5
+ afterChange: [afterRequiresReviewDisabled],
4
6
  };
@@ -72,7 +72,7 @@ export function buildTiersCollection() {
72
72
  type: 'checkbox',
73
73
  defaultValue: false,
74
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.',
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. Turning it off approves pending requests for this tier automatically.',
76
76
  },
77
77
  },
78
78
  {
@@ -1,7 +1,7 @@
1
1
  import type { Endpoint } from 'payload';
2
2
  /**
3
- * `POST` repair progression from existing grants.
4
- * Body (optional): `{ userId?, scopeId?, limit? }`.
3
+ * `POST` repair progression from existing grants / requests.
4
+ * Body (optional): `{ userId?, scopeId?, achievementId?, achievementSlug?, tierId?, tierSlug?, limit? }`.
5
5
  * Requires host `canReview`.
6
6
  */
7
7
  export declare function buildReconcileEndpoint(path: string): Endpoint;
@@ -1 +1 @@
1
- {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/endpoints/reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAkB,MAAM,SAAS,CAAA;AAsBvD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAsD7D"}
1
+ {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/endpoints/reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAkB,MAAM,SAAS,CAAA;AAoCvD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CA2C7D"}
@@ -1,6 +1,6 @@
1
1
  import { getUserId } from '../access/helpers/index';
2
2
  import { getAchievementOptions } from '../options-store';
3
- import { reconcileProgression, reconcileUserProgression } from '../services/achievement/reconcile';
3
+ import { reconcileProgression } from '../services/achievement/reconcile';
4
4
  function requestUrl(req) {
5
5
  return req.url ? new URL(req.url, 'http://local') : null;
6
6
  }
@@ -17,9 +17,20 @@ async function readBody(req) {
17
17
  }
18
18
  return {};
19
19
  }
20
+ function stringParam(body, url, keys) {
21
+ for (const key of keys) {
22
+ const fromBody = body[key];
23
+ if (typeof fromBody === 'string' && fromBody.length > 0)
24
+ return fromBody;
25
+ const fromUrl = url?.searchParams.get(key);
26
+ if (fromUrl && fromUrl.length > 0)
27
+ return fromUrl;
28
+ }
29
+ return null;
30
+ }
20
31
  /**
21
- * `POST` repair progression from existing grants.
22
- * Body (optional): `{ userId?, scopeId?, limit? }`.
32
+ * `POST` repair progression from existing grants / requests.
33
+ * Body (optional): `{ userId?, scopeId?, achievementId?, achievementSlug?, tierId?, tierSlug?, limit? }`.
23
34
  * Requires host `canReview`.
24
35
  */
25
36
  export function buildReconcileEndpoint(path) {
@@ -37,31 +48,23 @@ export function buildReconcileEndpoint(path) {
37
48
  }
38
49
  const body = await readBody(req);
39
50
  const url = requestUrl(req);
40
- const scopeIdRaw = (typeof body.scopeId === 'string' && body.scopeId) ||
41
- url?.searchParams.get('scopeId') ||
42
- url?.searchParams.get('scope') ||
43
- null;
44
- const scopeId = scopeIdRaw && scopeIdRaw.length > 0 ? scopeIdRaw : null;
51
+ const scopeId = stringParam(body, url, ['scopeId', 'scope']);
45
52
  if (!(await canReview(req.user, scopeId))) {
46
53
  return Response.json({ errors: [{ message: 'Forbidden' }] }, { status: 403 });
47
54
  }
48
- const userId = typeof body.userId === 'string' && body.userId.length > 0 ? body.userId : null;
49
- const limit = typeof body.limit === 'number' && Number.isFinite(body.limit)
50
- ? Math.floor(body.limit)
55
+ const limitRaw = body.limit;
56
+ const limit = typeof limitRaw === 'number' && Number.isFinite(limitRaw)
57
+ ? Math.floor(limitRaw)
51
58
  : undefined;
52
- if (userId) {
53
- const result = await reconcileUserProgression({
54
- payload: req.payload,
55
- req,
56
- userId,
57
- scopeId,
58
- });
59
- return Response.json({ ok: true, usersScanned: 1, ...result });
60
- }
61
59
  const result = await reconcileProgression({
62
60
  payload: req.payload,
63
61
  req,
64
62
  scopeId,
63
+ userId: stringParam(body, url, ['userId', 'user']),
64
+ achievementId: stringParam(body, url, ['achievementId']),
65
+ achievementSlug: stringParam(body, url, ['achievementSlug', 'achievement']),
66
+ tierId: stringParam(body, url, ['tierId']),
67
+ tierSlug: stringParam(body, url, ['tierSlug', 'tier']),
65
68
  limit,
66
69
  });
67
70
  return Response.json({ ok: true, ...result });
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ export type { EngineEventTypeSlug, EngineMetricSlug } from './catalog';
3
3
  export { achievementPlugin } from './plugin';
4
4
  export { seedAchievementCatalog, seedAchievements } from './seed/index';
5
5
  export type { AchievementSeedAchievement, AchievementSeedCatalogRow, AchievementSeedInput, AchievementSeedLocalizedText, AchievementSeedTier, } from './seed/index';
6
- export { evaluateRules, evaluateRuleProgress, collectAchievementCompleteRefs, buildCatalogProgressGroups, getUserProgress, grantAchievement, recordEvent, recordLog, recordMetricChange, reconcileProgression, reconcileUserProgression, resolveCurrentTier, resolveTierProgress, reviewAchievementRequest, submitAchievementRequest, syncTierChangedLog, syncTierProgression, syncCompositeProgression, validateRuleGroup, validateRuleNode, } from './services/achievement/index';
7
- export type { ResolvedTier, RuleGroup, TierLadderProgress, CatalogProgressGroup, CatalogProgressItem, ReconcileProgressionResult, } from './services/achievement/index';
6
+ export { evaluateRules, evaluateRuleProgress, collectAchievementCompleteRefs, buildCatalogProgressGroups, getUserProgress, grantAchievement, recordEvent, recordLog, recordMetricChange, reconcileProgression, reconcileUserProgression, releasePendingAchievementReviews, releasePendingTierReviews, resolveCurrentTier, resolveTierProgress, reviewAchievementRequest, submitAchievementRequest, syncTierChangedLog, syncTierProgression, syncCompositeProgression, catalogRef, transitionLogData, transitionToId, validateRuleGroup, validateRuleNode, } from './services/achievement/index';
7
+ export type { ResolvedTier, RuleGroup, TierLadderProgress, CatalogProgressGroup, CatalogProgressItem, CatalogRef, TransitionLogData, ReconcileProgressionArgs, ReconcileProgressionResult, ReleaseReviewGateResult, } from './services/achievement/index';
8
8
  export type { BuiltInEventType, BuiltInRuleType, AchievementCollectionKey, AchievementCollectionOverride, AchievementCollectionsOptions, AchievementEndpointsOptions, AchievementPluginOptions, AchievementRuleType, AchievementScopeConfig, AchievementUsersOptions, } from './types';
9
9
  export { DEFAULT_COLLECTION_BASE_SLUGS, DEFAULT_COLLECTION_PREFIX, DEFAULT_ADMIN_GROUP } from './collections/slugs';
10
10
  export { DEFAULT_ME_ENDPOINT_PATH, DEFAULT_RECONCILE_ENDPOINT_PATH } from './defaults';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,0BAA0B,EAC1B,+BAA+B,EAC/B,kBAAkB,EAClB,cAAc,GACf,MAAM,WAAW,CAAA;AAClB,YAAY,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAA;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAA;AAC5C,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAA;AACvE,YAAY,EACV,0BAA0B,EAC1B,yBAAyB,EACzB,oBAAoB,EACpB,4BAA4B,EAC5B,mBAAmB,GACpB,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,aAAa,EACb,oBAAoB,EACpB,8BAA8B,EAC9B,0BAA0B,EAC1B,eAAe,EACf,gBAAgB,EAChB,WAAW,EACX,SAAS,EACT,kBAAkB,EAClB,oBAAoB,EACpB,wBAAwB,EACxB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,wBAAwB,EACxB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,8BAA8B,CAAA;AACrC,YAAY,EACV,YAAY,EACZ,SAAS,EACT,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,8BAA8B,CAAA;AACrC,YAAY,EACV,gBAAgB,EAChB,eAAe,EACf,wBAAwB,EACxB,6BAA6B,EAC7B,6BAA6B,EAC7B,2BAA2B,EAC3B,wBAAwB,EACxB,mBAAmB,EACnB,sBAAsB,EACtB,uBAAuB,GACxB,MAAM,SAAS,CAAA;AAChB,OAAO,EAAE,6BAA6B,EAAE,yBAAyB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AACnH,OAAO,EAAE,wBAAwB,EAAE,+BAA+B,EAAE,MAAM,YAAY,CAAA;AACtF,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAA;AAC5D,OAAO,EAAE,0BAA0B,EAAE,MAAM,WAAW,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,0BAA0B,EAC1B,+BAA+B,EAC/B,kBAAkB,EAClB,cAAc,GACf,MAAM,WAAW,CAAA;AAClB,YAAY,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAA;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAA;AAC5C,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAA;AACvE,YAAY,EACV,0BAA0B,EAC1B,yBAAyB,EACzB,oBAAoB,EACpB,4BAA4B,EAC5B,mBAAmB,GACpB,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,aAAa,EACb,oBAAoB,EACpB,8BAA8B,EAC9B,0BAA0B,EAC1B,eAAe,EACf,gBAAgB,EAChB,WAAW,EACX,SAAS,EACT,kBAAkB,EAClB,oBAAoB,EACpB,wBAAwB,EACxB,gCAAgC,EAChC,yBAAyB,EACzB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,wBAAwB,EACxB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,UAAU,EACV,iBAAiB,EACjB,cAAc,EACd,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,8BAA8B,CAAA;AACrC,YAAY,EACV,YAAY,EACZ,SAAS,EACT,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,UAAU,EACV,iBAAiB,EACjB,wBAAwB,EACxB,0BAA0B,EAC1B,uBAAuB,GACxB,MAAM,8BAA8B,CAAA;AACrC,YAAY,EACV,gBAAgB,EAChB,eAAe,EACf,wBAAwB,EACxB,6BAA6B,EAC7B,6BAA6B,EAC7B,2BAA2B,EAC3B,wBAAwB,EACxB,mBAAmB,EACnB,sBAAsB,EACtB,uBAAuB,GACxB,MAAM,SAAS,CAAA;AAChB,OAAO,EAAE,6BAA6B,EAAE,yBAAyB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AACnH,OAAO,EAAE,wBAAwB,EAAE,+BAA+B,EAAE,MAAM,YAAY,CAAA;AACtF,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAA;AAC5D,OAAO,EAAE,0BAA0B,EAAE,MAAM,WAAW,CAAA"}
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { DEFAULT_ACHIEVEMENT_METRIC, DEFAULT_ACHIEVEMENT_METRIC_SLUG, ENGINE_EVENT_TYPES, ENGINE_METRICS, } from './catalog';
2
2
  export { achievementPlugin } from './plugin';
3
3
  export { seedAchievementCatalog, seedAchievements } from './seed/index';
4
- export { evaluateRules, evaluateRuleProgress, collectAchievementCompleteRefs, buildCatalogProgressGroups, getUserProgress, grantAchievement, recordEvent, recordLog, recordMetricChange, reconcileProgression, reconcileUserProgression, resolveCurrentTier, resolveTierProgress, reviewAchievementRequest, submitAchievementRequest, syncTierChangedLog, syncTierProgression, syncCompositeProgression, validateRuleGroup, validateRuleNode, } from './services/achievement/index';
4
+ export { evaluateRules, evaluateRuleProgress, collectAchievementCompleteRefs, buildCatalogProgressGroups, getUserProgress, grantAchievement, recordEvent, recordLog, recordMetricChange, reconcileProgression, reconcileUserProgression, releasePendingAchievementReviews, releasePendingTierReviews, resolveCurrentTier, resolveTierProgress, reviewAchievementRequest, submitAchievementRequest, syncTierChangedLog, syncTierProgression, syncCompositeProgression, catalogRef, transitionLogData, transitionToId, validateRuleGroup, validateRuleNode, } from './services/achievement/index';
5
5
  export { DEFAULT_COLLECTION_BASE_SLUGS, DEFAULT_COLLECTION_PREFIX, DEFAULT_ADMIN_GROUP } from './collections/slugs';
6
6
  export { DEFAULT_ME_ENDPOINT_PATH, DEFAULT_RECONCILE_ENDPOINT_PATH } from './defaults';
7
7
  export { collectionOf, slugOf } from './collections/helpers';
@@ -1 +1 @@
1
- {"version":3,"file":"grantSideEffects.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/grantSideEffects.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAS,MAAM,SAAS,CAAA;AAUpD,OAAO,EAAE,oBAAoB,EAAE,2BAA2B,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAStG,wEAAwE;AACxE,wBAAsB,cAAc,CAAC,IAAI,EAAE;IACzC,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CA+BnC;AAED,6EAA6E;AAC7E,wBAAsB,wBAAwB,CAAC,IAAI,EAAE;IACnD,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,GAAG,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAuC9D;AAED;;GAEG;AACH,wBAAsB,wBAAwB,CAAC,IAAI,EAAE;IACnD,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,CAsElD;AAED,oFAAoF;AACpF,wBAAsB,0BAA0B,CAAC,IAAI,EAAE;IACrD,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiDlB;AAED,wBAAsB,oBAAoB,CAAC,IAAI,EAAE;IAC/C,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BhB;AAED,wBAAsB,oBAAoB,CAAC,IAAI,EAAE;IAC/C,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuChB"}
1
+ {"version":3,"file":"grantSideEffects.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/grantSideEffects.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAS,MAAM,SAAS,CAAA;AAWpD,OAAO,EAAE,oBAAoB,EAAE,2BAA2B,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAStG,wEAAwE;AACxE,wBAAsB,cAAc,CAAC,IAAI,EAAE;IACzC,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CA+BnC;AAED,6EAA6E;AAC7E,wBAAsB,wBAAwB,CAAC,IAAI,EAAE;IACnD,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,GAAG,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAuC9D;AAED;;GAEG;AACH,wBAAsB,wBAAwB,CAAC,IAAI,EAAE;IACnD,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,CAsElD;AAED,oFAAoF;AACpF,wBAAsB,0BAA0B,CAAC,IAAI,EAAE;IACrD,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiDlB;AAED,wBAAsB,oBAAoB,CAAC,IAAI,EAAE;IAC/C,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BhB;AAED,wBAAsB,oBAAoB,CAAC,IAAI,EAAE;IAC/C,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuChB"}
@@ -1,6 +1,7 @@
1
1
  import { collectionOf } from '../../collections/helpers';
2
2
  import { CTX_CASCADING_REVOKE, CTX_SYSTEM_REQUEST } from './contextFlags';
3
3
  import { evaluateRules, ruleGroupHasProgressRequirements } from './evaluateRules';
4
+ import { transitionLogData } from './logData';
4
5
  import { recordLog, syncTierChangedLog } from './recordEvent';
5
6
  import { relationId } from './relationId';
6
7
  import { syncTierProgression } from './tierProgression';
@@ -209,10 +210,10 @@ export async function runAfterGrantCreated(args) {
209
210
  userId: args.userId,
210
211
  scopeId: args.scopeId,
211
212
  type: 'achievement.granted',
212
- data: {
213
- achievement: args.achievementId,
214
- ...(args.achievementSlug ? { achievementSlug: args.achievementSlug } : {}),
215
- },
213
+ data: transitionLogData({
214
+ from: null,
215
+ to: { id: args.achievementId, slug: args.achievementSlug },
216
+ }),
216
217
  });
217
218
  await syncCompositeProgression({
218
219
  req: args.req,
@@ -238,10 +239,10 @@ export async function runAfterGrantDeleted(args) {
238
239
  userId: args.userId,
239
240
  scopeId: args.scopeId,
240
241
  type: 'achievement.revoked',
241
- data: {
242
- achievement: args.achievementId,
243
- ...(args.achievementSlug ? { achievementSlug: args.achievementSlug } : {}),
244
- },
242
+ data: transitionLogData({
243
+ from: { id: args.achievementId, slug: args.achievementSlug },
244
+ to: null,
245
+ }),
245
246
  });
246
247
  if (args.cascading)
247
248
  return;
@@ -14,5 +14,9 @@ export { validateRuleGroup, validateRuleNode } from './validateRules';
14
14
  export { syncCompositeProgression } from './grantSideEffects';
15
15
  export { syncTierProgression } from './tierProgression';
16
16
  export { reconcileProgression, reconcileUserProgression } from './reconcile';
17
- export type { ReconcileProgressionResult } from './reconcile';
17
+ export type { ReconcileProgressionArgs, ReconcileProgressionResult } from './reconcile';
18
+ export { didDisableRequiresReview, releasePendingAchievementReviews, releasePendingTierReviews, } from './releaseReviewGate';
19
+ export type { ReleaseReviewGateResult } from './releaseReviewGate';
20
+ export { catalogRef, transitionLogData, transitionToId, } from './logData';
21
+ export type { CatalogRef, TransitionLogData } from './logData';
18
22
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,8BAA8B,EAAE,MAAM,iBAAiB,CAAA;AACzH,YAAY,EAAE,SAAS,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAA;AACxE,OAAO,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAA;AACzE,YAAY,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAA;AAC7F,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAC9F,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACxD,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAC/D,OAAO,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAA;AACvF,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAA;AACrE,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AACrE,OAAO,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAA;AAC7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AACvD,OAAO,EAAE,oBAAoB,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AAC5E,YAAY,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,8BAA8B,EAAE,MAAM,iBAAiB,CAAA;AACzH,YAAY,EAAE,SAAS,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAA;AACxE,OAAO,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAA;AACzE,YAAY,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAA;AAC7F,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAC9F,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACxD,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAC/D,OAAO,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAA;AACvF,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAA;AACrE,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AACrE,OAAO,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAA;AAC7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AACvD,OAAO,EAAE,oBAAoB,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AAC5E,YAAY,EAAE,wBAAwB,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AACvF,OAAO,EACL,wBAAwB,EACxB,gCAAgC,EAChC,yBAAyB,GAC1B,MAAM,qBAAqB,CAAA;AAC5B,YAAY,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAA;AAClE,OAAO,EACL,UAAU,EACV,iBAAiB,EACjB,cAAc,GACf,MAAM,WAAW,CAAA;AAClB,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA"}
@@ -10,3 +10,5 @@ export { validateRuleGroup, validateRuleNode } from './validateRules';
10
10
  export { syncCompositeProgression } from './grantSideEffects';
11
11
  export { syncTierProgression } from './tierProgression';
12
12
  export { reconcileProgression, reconcileUserProgression } from './reconcile';
13
+ export { didDisableRequiresReview, releasePendingAchievementReviews, releasePendingTierReviews, } from './releaseReviewGate';
14
+ export { catalogRef, transitionLogData, transitionToId, } from './logData';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared engine shape for achievement-log `data` on system event types.
3
+ * Hosts may add extra keys; readers should treat unknown keys as opaque.
4
+ *
5
+ * Transition events always use `from` / `to` (either side may be null):
6
+ * - `tier.changed` — previous ladder step → current step
7
+ * - `achievement.granted` — `from: null`, `to: achievement`
8
+ * - `achievement.revoked` — `from: achievement`, `to: null`
9
+ */
10
+ export type CatalogRef = {
11
+ id: string;
12
+ slug?: string;
13
+ };
14
+ export type TransitionLogData = {
15
+ from: CatalogRef | null;
16
+ to: CatalogRef | null;
17
+ };
18
+ export declare function catalogRef(id: string, slug?: string | null): CatalogRef;
19
+ export declare function transitionLogData(args: {
20
+ from?: {
21
+ id: string;
22
+ slug?: string | null;
23
+ } | null;
24
+ to?: {
25
+ id: string;
26
+ slug?: string | null;
27
+ } | null;
28
+ }): TransitionLogData;
29
+ /** Read the “current” catalog id from a log `data` blob (new or legacy shapes). */
30
+ export declare function transitionToId(data: unknown): string | null;
31
+ //# sourceMappingURL=logData.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logData.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/logData.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IACvB,EAAE,EAAE,UAAU,GAAG,IAAI,CAAA;CACtB,CAAA;AAED,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,UAAU,CAKvE;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,IAAI,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAA;IAClD,EAAE,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAA;CACjD,GAAG,iBAAiB,CAKpB;AAED,mFAAmF;AACnF,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAY3D"}
@@ -0,0 +1,30 @@
1
+ export function catalogRef(id, slug) {
2
+ return {
3
+ id,
4
+ ...(typeof slug === 'string' && slug.length > 0 ? { slug } : {}),
5
+ };
6
+ }
7
+ export function transitionLogData(args) {
8
+ return {
9
+ from: args.from ? catalogRef(args.from.id, args.from.slug) : null,
10
+ to: args.to ? catalogRef(args.to.id, args.to.slug) : null,
11
+ };
12
+ }
13
+ /** Read the “current” catalog id from a log `data` blob (new or legacy shapes). */
14
+ export function transitionToId(data) {
15
+ if (!data || typeof data !== 'object' || Array.isArray(data))
16
+ return null;
17
+ const record = data;
18
+ const to = record.to;
19
+ if (to && typeof to === 'object' && !Array.isArray(to)) {
20
+ const id = to.id;
21
+ if (typeof id === 'string' && id)
22
+ return id;
23
+ }
24
+ // Legacy tier.changed / achievement.* shapes
25
+ if (typeof record.tier === 'string' && record.tier)
26
+ return record.tier;
27
+ if (typeof record.achievement === 'string' && record.achievement)
28
+ return record.achievement;
29
+ return null;
30
+ }
@@ -7,6 +7,20 @@ export type ReconcileProgressionResult = {
7
7
  tierRequestsEnsured: number;
8
8
  tierLogsWritten: number;
9
9
  };
10
+ export type ReconcileProgressionArgs = {
11
+ payload: Payload;
12
+ req?: PayloadRequest;
13
+ /** Limit to one member. */
14
+ userId?: string | null;
15
+ scopeId?: string | null;
16
+ /** Limit discovery to grants / requests for this achievement (id or slug). */
17
+ achievementId?: string | null;
18
+ achievementSlug?: string | null;
19
+ /** Limit discovery to tier requests for this tier (id or slug). */
20
+ tierId?: string | null;
21
+ tierSlug?: string | null;
22
+ limit?: number;
23
+ };
10
24
  /** Repair progression for one user (+ optional scope) from current grants + catalog. */
11
25
  export declare function reconcileUserProgression(args: {
12
26
  payload: Payload;
@@ -14,11 +28,9 @@ export declare function reconcileUserProgression(args: {
14
28
  userId: string;
15
29
  scopeId?: string | null;
16
30
  }): Promise<Omit<ReconcileProgressionResult, 'usersScanned'>>;
17
- /** Scan grants and reconcile each distinct (user, scope) pair. */
18
- export declare function reconcileProgression(args: {
19
- payload: Payload;
20
- req?: PayloadRequest;
21
- scopeId?: string | null;
22
- limit?: number;
23
- }): Promise<ReconcileProgressionResult>;
31
+ /**
32
+ * Scan matching (user, scope) pairs and repair progression.
33
+ * Optional filters: `userId`, `scopeId`, `achievementId`/`achievementSlug`, `tierId`/`tierSlug`.
34
+ */
35
+ export declare function reconcileProgression(args: ReconcileProgressionArgs): Promise<ReconcileProgressionResult>;
24
36
  //# sourceMappingURL=reconcile.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAS,MAAM,SAAS,CAAA;AAY7D,MAAM,MAAM,0BAA0B,GAAG;IACvC,YAAY,EAAE,MAAM,CAAA;IACpB,qBAAqB,EAAE,MAAM,CAAA;IAC7B,iBAAiB,EAAE,MAAM,CAAA;IACzB,0BAA0B,EAAE,MAAM,CAAA;IAClC,mBAAmB,EAAE,MAAM,CAAA;IAC3B,eAAe,EAAE,MAAM,CAAA;CACxB,CAAA;AAoFD,wFAAwF;AACxF,wBAAsB,wBAAwB,CAAC,IAAI,EAAE;IACnD,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,GAAG,OAAO,CAAC,IAAI,CAAC,0BAA0B,EAAE,cAAc,CAAC,CAAC,CAoC5D;AAOD,kEAAkE;AAClE,wBAAsB,oBAAoB,CAAC,IAAI,EAAE;IAC/C,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAyDtC"}
1
+ {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAS,MAAM,SAAS,CAAA;AAa7D,MAAM,MAAM,0BAA0B,GAAG;IACvC,YAAY,EAAE,MAAM,CAAA;IACpB,qBAAqB,EAAE,MAAM,CAAA;IAC7B,iBAAiB,EAAE,MAAM,CAAA;IACzB,0BAA0B,EAAE,MAAM,CAAA;IAClC,mBAAmB,EAAE,MAAM,CAAA;IAC3B,eAAe,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,wBAAwB,GAAG;IACrC,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,8EAA8E;IAC9E,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAwFD,wFAAwF;AACxF,wBAAsB,wBAAwB,CAAC,IAAI,EAAE;IACnD,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,GAAG,OAAO,CAAC,IAAI,CAAC,0BAA0B,EAAE,cAAc,CAAC,CAAC,CAoC5D;AA2LD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,IAAI,EAAE,wBAAwB,GAC7B,OAAO,CAAC,0BAA0B,CAAC,CA8CrC"}
@@ -1,5 +1,6 @@
1
1
  import { collectionOf } from '../../collections/helpers';
2
2
  import { syncCompositeProgression, } from './grantSideEffects';
3
+ import { transitionLogData, transitionToId } from './logData';
3
4
  import { recordLog, syncTierChangedLog } from './recordEvent';
4
5
  import { relationId } from './relationId';
5
6
  import { resolveCatalogId } from './resolveCatalog';
@@ -33,7 +34,7 @@ async function backfillGrantedLogs(args) {
33
34
  },
34
35
  });
35
36
  const logged = new Set(logs.docs
36
- .map((log) => relationId(log.data?.achievement))
37
+ .map((log) => transitionToId(log.data))
37
38
  .filter((id) => Boolean(id)));
38
39
  let written = 0;
39
40
  for (const grant of grants.docs) {
@@ -63,10 +64,12 @@ async function backfillGrantedLogs(args) {
63
64
  scopeId: args.scopeId,
64
65
  type: 'achievement.granted',
65
66
  data: {
66
- achievement: achievementId,
67
- ...(slug ? { achievementSlug: slug } : {}),
68
- backfilled: true,
67
+ ...transitionLogData({
68
+ from: null,
69
+ to: { id: achievementId, slug },
70
+ }),
69
71
  ...(completedAt ? { completedAt } : {}),
72
+ backfilled: true,
70
73
  },
71
74
  });
72
75
  logged.add(achievementId);
@@ -113,7 +116,140 @@ function scopeKey(scope) {
113
116
  const id = relationId(scope);
114
117
  return id ?? '';
115
118
  }
116
- /** Scan grants and reconcile each distinct (user, scope) pair. */
119
+ function addPair(pairs, userId, scopeId, filterUserId, filterScopeId) {
120
+ if (!userId)
121
+ return;
122
+ if (filterUserId && userId !== filterUserId)
123
+ return;
124
+ if (filterScopeId != null && filterScopeId !== '' && scopeId !== filterScopeId)
125
+ return;
126
+ const key = `${userId}::${scopeKey(scopeId)}`;
127
+ if (!pairs.has(key))
128
+ pairs.set(key, { userId, scopeId });
129
+ }
130
+ async function resolveDefinitionId(args) {
131
+ if (args.id && args.id.length > 0)
132
+ return args.id;
133
+ if (!args.slug || args.slug.length === 0)
134
+ return null;
135
+ const found = await args.payload.find({
136
+ collection: collectionOf(args.collection),
137
+ depth: 0,
138
+ limit: 1,
139
+ overrideAccess: true,
140
+ req: args.req,
141
+ where: { slug: { equals: args.slug } },
142
+ });
143
+ return relationId(found.docs[0]?.id) ?? null;
144
+ }
145
+ async function collectPairs(args) {
146
+ const pairs = new Map();
147
+ const filterUserId = args.userId?.trim() || null;
148
+ const filterScopeId = args.scopeId ?? null;
149
+ const achievementId = await resolveDefinitionId({
150
+ payload: args.payload,
151
+ req: args.req,
152
+ collection: 'achievements',
153
+ id: args.achievementId,
154
+ slug: args.achievementSlug,
155
+ });
156
+ const tierId = await resolveDefinitionId({
157
+ payload: args.payload,
158
+ req: args.req,
159
+ collection: 'tiers',
160
+ id: args.tierId,
161
+ slug: args.tierSlug,
162
+ });
163
+ if (filterUserId && !achievementId && !tierId) {
164
+ addPair(pairs, filterUserId, filterScopeId);
165
+ return [...pairs.values()];
166
+ }
167
+ if (achievementId) {
168
+ const grantWhere = {
169
+ and: [
170
+ { achievement: { equals: achievementId } },
171
+ ...(filterUserId ? [{ user: { equals: filterUserId } }] : []),
172
+ ...(filterScopeId
173
+ ? [{ scope: { equals: filterScopeId } }]
174
+ : []),
175
+ ],
176
+ };
177
+ const grants = await args.payload.find({
178
+ collection: collectionOf('grants'),
179
+ depth: 0,
180
+ limit: args.limit,
181
+ pagination: false,
182
+ overrideAccess: true,
183
+ req: args.req,
184
+ where: grantWhere,
185
+ });
186
+ for (const grant of grants.docs) {
187
+ addPair(pairs, relationId(grant.user), relationId(grant.scope), filterUserId, filterScopeId);
188
+ }
189
+ const requestWhere = {
190
+ and: [
191
+ { achievement: { equals: achievementId } },
192
+ ...(filterUserId ? [{ user: { equals: filterUserId } }] : []),
193
+ ...(filterScopeId ? [{ scope: { equals: filterScopeId } }] : []),
194
+ ],
195
+ };
196
+ const requests = await args.payload.find({
197
+ collection: collectionOf('achievementRequests'),
198
+ depth: 0,
199
+ limit: args.limit,
200
+ pagination: false,
201
+ overrideAccess: true,
202
+ req: args.req,
203
+ where: requestWhere,
204
+ });
205
+ for (const request of requests.docs) {
206
+ addPair(pairs, relationId(request.user), relationId(request.scope), filterUserId, filterScopeId);
207
+ }
208
+ }
209
+ if (tierId) {
210
+ const requestWhere = {
211
+ and: [
212
+ { tier: { equals: tierId } },
213
+ ...(filterUserId ? [{ user: { equals: filterUserId } }] : []),
214
+ ...(filterScopeId ? [{ scope: { equals: filterScopeId } }] : []),
215
+ ],
216
+ };
217
+ const requests = await args.payload.find({
218
+ collection: collectionOf('tierRequests'),
219
+ depth: 0,
220
+ limit: args.limit,
221
+ pagination: false,
222
+ overrideAccess: true,
223
+ req: args.req,
224
+ where: requestWhere,
225
+ });
226
+ for (const request of requests.docs) {
227
+ addPair(pairs, relationId(request.user), relationId(request.scope), filterUserId, filterScopeId);
228
+ }
229
+ }
230
+ if (!achievementId && !tierId) {
231
+ const where = filterScopeId
232
+ ? { scope: { equals: filterScopeId } }
233
+ : undefined;
234
+ const grants = await args.payload.find({
235
+ collection: collectionOf('grants'),
236
+ depth: 0,
237
+ limit: args.limit,
238
+ pagination: false,
239
+ overrideAccess: true,
240
+ req: args.req,
241
+ ...(where ? { where } : {}),
242
+ });
243
+ for (const grant of grants.docs) {
244
+ addPair(pairs, relationId(grant.user), relationId(grant.scope), filterUserId, filterScopeId);
245
+ }
246
+ }
247
+ return [...pairs.values()];
248
+ }
249
+ /**
250
+ * Scan matching (user, scope) pairs and repair progression.
251
+ * Optional filters: `userId`, `scopeId`, `achievementId`/`achievementSlug`, `tierId`/`tierSlug`.
252
+ */
117
253
  export async function reconcileProgression(args) {
118
254
  const req = args.req ??
119
255
  {
@@ -122,28 +258,17 @@ export async function reconcileProgression(args) {
122
258
  };
123
259
  if (!req.context)
124
260
  req.context = {};
125
- const where = args.scopeId
126
- ? { scope: { equals: args.scopeId } }
127
- : undefined;
128
- const grants = await args.payload.find({
129
- collection: collectionOf('grants'),
130
- depth: 0,
131
- limit: args.limit ?? 5000,
132
- pagination: false,
133
- overrideAccess: true,
261
+ const pairs = await collectPairs({
262
+ payload: args.payload,
134
263
  req,
135
- ...(where ? { where } : {}),
264
+ userId: args.userId,
265
+ scopeId: args.scopeId,
266
+ achievementId: args.achievementId,
267
+ achievementSlug: args.achievementSlug,
268
+ tierId: args.tierId,
269
+ tierSlug: args.tierSlug,
270
+ limit: args.limit ?? 5000,
136
271
  });
137
- const pairs = new Map();
138
- for (const grant of grants.docs) {
139
- const userId = relationId(grant.user);
140
- if (!userId)
141
- continue;
142
- const scopeId = relationId(grant.scope);
143
- const key = `${userId}::${scopeKey(scopeId)}`;
144
- if (!pairs.has(key))
145
- pairs.set(key, { userId, scopeId });
146
- }
147
272
  const totals = {
148
273
  usersScanned: 0,
149
274
  grantedLogsBackfilled: 0,
@@ -152,7 +277,7 @@ export async function reconcileProgression(args) {
152
277
  tierRequestsEnsured: 0,
153
278
  tierLogsWritten: 0,
154
279
  };
155
- for (const pair of pairs.values()) {
280
+ for (const pair of pairs) {
156
281
  const result = await reconcileUserProgression({
157
282
  payload: args.payload,
158
283
  req,
@@ -1 +1 @@
1
- {"version":3,"file":"recordEvent.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/recordEvent.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAWtD,MAAM,MAAM,cAAc,GAAG;IAC3B,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;CACtC,CAAA;AAgBD,6DAA6D;AAC7D,wBAAsB,SAAS,CAAC,IAAI,EAAE,cAAc,wEAuCnD;AAED,4CAA4C;AAC5C,eAAO,MAAM,WAAW,kBAAY,CAAA;AAEpC;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE;IAC7C,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,wEAiCA;AAED,6FAA6F;AAC7F,wBAAsB,kBAAkB,CAAC,IAAI,EAAE;IAC7C,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,iFAgDA"}
1
+ {"version":3,"file":"recordEvent.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/recordEvent.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAWtD,MAAM,MAAM,cAAc,GAAG;IAC3B,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;CACtC,CAAA;AAgBD,6DAA6D;AAC7D,wBAAsB,SAAS,CAAC,IAAI,EAAE,cAAc,wEAuCnD;AAED,4CAA4C;AAC5C,eAAO,MAAM,WAAW,kBAAY,CAAA;AAEpC;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE;IAC7C,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,wEAiCA;AAED,6FAA6F;AAC7F,wBAAsB,kBAAkB,CAAC,IAAI,EAAE;IAC7C,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,iFA0DA"}
@@ -2,8 +2,8 @@ import { APIError } from 'payload';
2
2
  import { DEFAULT_ACHIEVEMENT_METRIC_SLUG } from '../../catalog';
3
3
  import { collectionOf } from '../../collections/helpers';
4
4
  import { eventTypeRequiresActor } from './eventTypeRequiresActor';
5
+ import { transitionLogData, transitionToId } from './logData';
5
6
  import { resolveCatalogId } from './resolveCatalog';
6
- import { relationId } from './relationId';
7
7
  import { resolveCurrentTier } from './resolveCurrentTier';
8
8
  import { userScopeWhere } from './where';
9
9
  async function catalogId(args) {
@@ -113,22 +113,35 @@ export async function syncTierChangedLog(args) {
113
113
  and: [userScopeWhere(args.userId, scopeId), { type: { equals: typeId } }],
114
114
  },
115
115
  });
116
- const lastTierId = relationId(latest.docs[0]?.data?.tier);
116
+ const latestData = latest.docs[0]?.data;
117
+ const lastTierId = transitionToId(latestData);
117
118
  const currentId = current?.id ?? null;
118
119
  if (lastTierId === currentId)
119
120
  return null;
120
121
  if (!currentId)
121
122
  return null;
123
+ let fromSlug;
124
+ if (latestData && typeof latestData === 'object' && !Array.isArray(latestData)) {
125
+ const record = latestData;
126
+ const to = record.to;
127
+ if (to && typeof to === 'object' && !Array.isArray(to)) {
128
+ const slug = to.slug;
129
+ if (typeof slug === 'string')
130
+ fromSlug = slug;
131
+ }
132
+ else if (typeof record.tierSlug === 'string') {
133
+ fromSlug = record.tierSlug;
134
+ }
135
+ }
122
136
  return recordLog({
123
137
  payload: args.payload,
124
138
  req: args.req,
125
139
  userId: args.userId,
126
140
  scopeId,
127
141
  type: 'tier.changed',
128
- data: {
129
- tier: currentId,
130
- ...(current?.slug ? { tierSlug: current.slug } : {}),
131
- ...(lastTierId ? { previousTier: lastTierId } : {}),
132
- },
142
+ data: transitionLogData({
143
+ from: lastTierId ? { id: lastTierId, slug: fromSlug } : null,
144
+ to: { id: currentId, slug: current?.slug },
145
+ }),
133
146
  });
134
147
  }
@@ -0,0 +1,23 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ export type ReleaseReviewGateResult = {
3
+ requestsApproved: number;
4
+ };
5
+ /**
6
+ * When a definition stops requiring review, approve each pending request for that
7
+ * definition so existing grant / tier.changed hooks run for that user + scope only.
8
+ */
9
+ export declare function releasePendingAchievementReviews(args: {
10
+ req: PayloadRequest;
11
+ achievementId: string;
12
+ }): Promise<ReleaseReviewGateResult>;
13
+ export declare function releasePendingTierReviews(args: {
14
+ req: PayloadRequest;
15
+ tierId: string;
16
+ }): Promise<ReleaseReviewGateResult>;
17
+ /** True when `requiresReview` flipped from required → not required. */
18
+ export declare function didDisableRequiresReview(previousDoc: {
19
+ requiresReview?: boolean | null;
20
+ } | null | undefined, doc: {
21
+ requiresReview?: boolean | null;
22
+ } | null | undefined): boolean;
23
+ //# sourceMappingURL=releaseReviewGate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"releaseReviewGate.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/releaseReviewGate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAK7C,MAAM,MAAM,uBAAuB,GAAG;IACpC,gBAAgB,EAAE,MAAM,CAAA;CACzB,CAAA;AAED;;;GAGG;AACH,wBAAsB,gCAAgC,CAAC,IAAI,EAAE;IAC3D,GAAG,EAAE,cAAc,CAAA;IACnB,aAAa,EAAE,MAAM,CAAA;CACtB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAgCnC;AAED,wBAAsB,yBAAyB,CAAC,IAAI,EAAE;IACpD,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;CACf,GAAG,OAAO,CAAC,uBAAuB,CAAC,CA6BnC;AAED,uEAAuE;AACvE,wBAAgB,wBAAwB,CACtC,WAAW,EAAE;IAAE,cAAc,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,EACnE,GAAG,EAAE;IAAE,cAAc,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAC1D,OAAO,CAET"}
@@ -0,0 +1,71 @@
1
+ import { collectionOf } from '../../collections/helpers';
2
+ import { relationId } from './relationId';
3
+ /**
4
+ * When a definition stops requiring review, approve each pending request for that
5
+ * definition so existing grant / tier.changed hooks run for that user + scope only.
6
+ */
7
+ export async function releasePendingAchievementReviews(args) {
8
+ const pending = await args.req.payload.find({
9
+ collection: collectionOf('achievementRequests'),
10
+ depth: 0,
11
+ limit: 5000,
12
+ pagination: false,
13
+ overrideAccess: true,
14
+ req: args.req,
15
+ where: {
16
+ and: [
17
+ { achievement: { equals: args.achievementId } },
18
+ { status: { equals: 'pending' } },
19
+ ],
20
+ },
21
+ });
22
+ let requestsApproved = 0;
23
+ for (const doc of pending.docs) {
24
+ const id = relationId(doc.id);
25
+ if (!id)
26
+ continue;
27
+ await args.req.payload.update({
28
+ collection: collectionOf('achievementRequests'),
29
+ id,
30
+ data: { status: 'approved' },
31
+ depth: 0,
32
+ overrideAccess: true,
33
+ req: args.req,
34
+ });
35
+ requestsApproved += 1;
36
+ }
37
+ return { requestsApproved };
38
+ }
39
+ export async function releasePendingTierReviews(args) {
40
+ const pending = await args.req.payload.find({
41
+ collection: collectionOf('tierRequests'),
42
+ depth: 0,
43
+ limit: 5000,
44
+ pagination: false,
45
+ overrideAccess: true,
46
+ req: args.req,
47
+ where: {
48
+ and: [{ tier: { equals: args.tierId } }, { status: { equals: 'pending' } }],
49
+ },
50
+ });
51
+ let requestsApproved = 0;
52
+ for (const doc of pending.docs) {
53
+ const id = relationId(doc.id);
54
+ if (!id)
55
+ continue;
56
+ await args.req.payload.update({
57
+ collection: collectionOf('tierRequests'),
58
+ id,
59
+ data: { status: 'approved' },
60
+ depth: 0,
61
+ overrideAccess: true,
62
+ req: args.req,
63
+ });
64
+ requestsApproved += 1;
65
+ }
66
+ return { requestsApproved };
67
+ }
68
+ /** True when `requiresReview` flipped from required → not required. */
69
+ export function didDisableRequiresReview(previousDoc, doc) {
70
+ return previousDoc?.requiresReview === true && doc?.requiresReview !== true;
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dappermountain/payload-plugin-achievements",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A robust, Admin-configured achievements engine for Payload: composable unlock rules, derived rank progress, request/review grants, metrics, and scoped multi-tenant support.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,6 +28,11 @@
28
28
  "import": "./dist/index.js",
29
29
  "default": "./dist/index.js"
30
30
  },
31
+ "./cli": {
32
+ "types": "./dist/cli/index.d.ts",
33
+ "import": "./dist/cli/index.js",
34
+ "default": "./dist/cli/index.js"
35
+ },
31
36
  "./types": {
32
37
  "types": "./dist/types.d.ts",
33
38
  "import": "./dist/types.js",