@dappermountain/payload-plugin-achievements 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,7 +97,7 @@ Slugs are prefixed with `achievement-` by default so they don’t collide with y
97
97
  | `metric.delta` | Metric change log type (requires metric + change amount) |
98
98
  | `achievement.granted` | Written when a grant is created |
99
99
  | `achievement.revoked` | Written when a grant is deleted |
100
- | `tier.changed` | Written when derived tier moves |
100
+ | `tier.changed` | Written when derived tier moves (including demotions when a tier request leaves `approved`) |
101
101
 
102
102
  Event types can require an **actor** and/or a **metric + change**. Admin only prompts for those fields when the type needs them.
103
103
 
@@ -110,7 +110,7 @@ Unlock (tiers), eligibility, and completion (achievements) share the same rule t
110
110
  - Built-in leaves → `tier-at-least`, `achievement-complete`, `metric-minimum`, `event-count`, `elapsed-since`
111
111
  - Custom leaves → register via `extensions.ruleTypes`
112
112
 
113
- **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`):
113
+ **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**. Turning **`requiresReview` off** approves pending tier requests and writes missing `tier.changed` logs for members who already meet unlock rules (including those who never had a pending request). **Next-tier fill** uses fractional progress on the same trees (`resolveTierProgress` / `evaluateRuleProgress`):
114
114
 
115
115
  | Combinator / leaf | Progress behavior |
116
116
  | --- | --- |
@@ -1 +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"}
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,yBAoBpC,CAAA"}
@@ -46,17 +46,14 @@ export const prepareTierRequest = async ({ data, operation, req, originalDoc, })
46
46
  }
47
47
  return data;
48
48
  };
49
- export const afterTierRequestChange = async ({ doc, operation, previousDoc, req, }) => {
50
- const status = doc.status;
51
- if (status !== 'approved')
52
- return doc;
53
- const previousStatus = operation === 'update' ? previousDoc?.status : undefined;
54
- if (previousStatus === 'approved')
55
- return doc;
49
+ export const afterTierRequestChange = async ({ doc, req, }) => {
56
50
  const userId = relationId(doc.user);
57
51
  if (!userId)
58
52
  return doc;
59
53
  const scopeId = scopeIdOf(doc);
54
+ // Any status transition (or create as pending/rejected/approved) can change the
55
+ // live ladder — including demotions when approved → pending/rejected. Sync is
56
+ // idempotent when the latest audit already matches resolveCurrentTier.
60
57
  await syncTierChangedLog({
61
58
  payload: req.payload,
62
59
  req: req,
@@ -1,7 +1,8 @@
1
1
  import type { CollectionAfterChangeHook } from 'payload';
2
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.
3
+ * When Requires review is turned off, approve pending tier requests and sync
4
+ * `tier.changed` for members who already met unlock rules (including those with
5
+ * no pending request — otherwise the UI advances with no audit until Repair).
5
6
  */
6
7
  export declare const afterRequiresReviewDisabled: CollectionAfterChangeHook;
7
8
  //# sourceMappingURL=afterRequiresReviewDisabled.d.ts.map
@@ -1 +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"}
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;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,EAAE,yBAmBzC,CAAA"}
@@ -1,7 +1,8 @@
1
1
  import { didDisableRequiresReview, releasePendingTierReviews, } from '../../../services/achievement/releaseReviewGate.js';
2
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.
3
+ * When Requires review is turned off, approve pending tier requests and sync
4
+ * `tier.changed` for members who already met unlock rules (including those with
5
+ * no pending request — otherwise the UI advances with no audit until Repair).
5
6
  */
6
7
  export const afterRequiresReviewDisabled = async ({ doc, operation, previousDoc, req, }) => {
7
8
  if (operation !== 'update')
@@ -1,6 +1,8 @@
1
1
  import type { PayloadRequest } from 'payload';
2
2
  export type ReleaseReviewGateResult = {
3
3
  requestsApproved: number;
4
+ /** `tier.changed` rows written for users who newly resolve past the released gate. */
5
+ tierLogsWritten?: number;
4
6
  };
5
7
  /**
6
8
  * When a definition stops requiring review, approve each pending request for that
@@ -10,6 +12,13 @@ export declare function releasePendingAchievementReviews(args: {
10
12
  req: PayloadRequest;
11
13
  achievementId: string;
12
14
  }): Promise<ReleaseReviewGateResult>;
15
+ /**
16
+ * When a tier stops requiring review:
17
+ * 1. Approve pending tier requests (their afterChange hooks sync `tier.changed`).
18
+ * 2. Sync `tier.changed` for every (user, scope) that may now resolve to this rung —
19
+ * including members who already met unlock rules but never had a pending request
20
+ * (UI would otherwise show the new rank with no audit log until Repair).
21
+ */
13
22
  export declare function releasePendingTierReviews(args: {
14
23
  req: PayloadRequest;
15
24
  tierId: string;
@@ -1 +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"}
1
+ {"version":3,"file":"releaseReviewGate.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/releaseReviewGate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAS,MAAM,SAAS,CAAA;AAMpD,MAAM,MAAM,uBAAuB,GAAG;IACpC,gBAAgB,EAAE,MAAM,CAAA;IACxB,sFAAsF;IACtF,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,CAAA;AAcD;;;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;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAAC,IAAI,EAAE;IACpD,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;CACf,GAAG,OAAO,CAAC,uBAAuB,CAAC,CA0FnC;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"}
@@ -1,5 +1,13 @@
1
1
  import { collectionOf } from '../../collections/helpers.js';
2
+ import { syncTierChangedLog } from './recordEvent.js';
2
3
  import { relationId } from './relationId.js';
4
+ function addUserScopePair(pairs, userId, scopeId) {
5
+ if (!userId)
6
+ return;
7
+ const key = `${userId}::${scopeId ?? ''}`;
8
+ if (!pairs.has(key))
9
+ pairs.set(key, { userId, scopeId });
10
+ }
3
11
  /**
4
12
  * When a definition stops requiring review, approve each pending request for that
5
13
  * definition so existing grant / tier.changed hooks run for that user + scope only.
@@ -36,6 +44,13 @@ export async function releasePendingAchievementReviews(args) {
36
44
  }
37
45
  return { requestsApproved };
38
46
  }
47
+ /**
48
+ * When a tier stops requiring review:
49
+ * 1. Approve pending tier requests (their afterChange hooks sync `tier.changed`).
50
+ * 2. Sync `tier.changed` for every (user, scope) that may now resolve to this rung —
51
+ * including members who already met unlock rules but never had a pending request
52
+ * (UI would otherwise show the new rank with no audit log until Repair).
53
+ */
39
54
  export async function releasePendingTierReviews(args) {
40
55
  const pending = await args.req.payload.find({
41
56
  collection: collectionOf('tierRequests'),
@@ -63,7 +78,56 @@ export async function releasePendingTierReviews(args) {
63
78
  });
64
79
  requestsApproved += 1;
65
80
  }
66
- return { requestsApproved };
81
+ const tier = (await args.req.payload.findByID({
82
+ collection: collectionOf('tiers'),
83
+ id: args.tierId,
84
+ depth: 0,
85
+ overrideAccess: true,
86
+ req: args.req,
87
+ }));
88
+ const tierScopeId = relationId(tier?.scope);
89
+ const pairs = new Map();
90
+ const allRequests = await args.req.payload.find({
91
+ collection: collectionOf('tierRequests'),
92
+ depth: 0,
93
+ limit: 5000,
94
+ pagination: false,
95
+ overrideAccess: true,
96
+ req: args.req,
97
+ where: { tier: { equals: args.tierId } },
98
+ });
99
+ for (const doc of allRequests.docs) {
100
+ addUserScopePair(pairs, relationId(doc.user), tierScopeId ?? relationId(doc.scope));
101
+ }
102
+ const grantWhere = tierScopeId
103
+ ? { scope: { equals: tierScopeId } }
104
+ : {
105
+ or: [{ scope: { exists: false } }, { scope: { equals: null } }],
106
+ };
107
+ const grants = await args.req.payload.find({
108
+ collection: collectionOf('grants'),
109
+ depth: 0,
110
+ limit: 5000,
111
+ pagination: false,
112
+ overrideAccess: true,
113
+ req: args.req,
114
+ where: grantWhere,
115
+ });
116
+ for (const doc of grants.docs) {
117
+ addUserScopePair(pairs, relationId(doc.user), tierScopeId ?? relationId(doc.scope));
118
+ }
119
+ let tierLogsWritten = 0;
120
+ for (const pair of pairs.values()) {
121
+ const log = await syncTierChangedLog({
122
+ payload: args.req.payload,
123
+ req: args.req,
124
+ userId: pair.userId,
125
+ scopeId: pair.scopeId,
126
+ });
127
+ if (log)
128
+ tierLogsWritten += 1;
129
+ }
130
+ return { requestsApproved, tierLogsWritten };
67
131
  }
68
132
  /** True when `requiresReview` flipped from required → not required. */
69
133
  export function didDisableRequiresReview(previousDoc, doc) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dappermountain/payload-plugin-achievements",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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",