@dappermountain/payload-plugin-achievements 0.2.12 → 0.2.14

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.
@@ -1 +1 @@
1
- {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../../../src/collections/Logs/fields/validators.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAQvC,eAAO,MAAM,aAAa,EAAE,QAW3B,CAAA;AAED,eAAO,MAAM,cAAc,EAAE,QAW5B,CAAA;AAED,eAAO,MAAM,cAAc,EAAE,QAW5B,CAAA"}
1
+ {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../../../src/collections/Logs/fields/validators.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AASvC,eAAO,MAAM,aAAa,EAAE,QAW3B,CAAA;AAED,eAAO,MAAM,cAAc,EAAE,QA0B5B,CAAA;AAED,eAAO,MAAM,cAAc,EAAE,QAW5B,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { collectionOf } from '../../../collections/helpers.js';
1
2
  import { eventTypeRequiresActor, eventTypeRequiresMetric, } from '../../../services/achievement/eventTypeRequiresActor.js';
2
3
  import { relationId } from '../../../services/achievement/relationId.js';
3
4
  export const validateActor = async (value, { data, req }) => {
@@ -23,9 +24,21 @@ export const validateMetric = async (value, { data, req }) => {
23
24
  if (!(await eventTypeRequiresMetric({ payload: req.payload, req, typeIdOrDoc: typeId }))) {
24
25
  return true;
25
26
  }
26
- if (!relationId(value)) {
27
+ const metricId = relationId(value);
28
+ if (!metricId) {
27
29
  return 'This log type needs a metric.';
28
30
  }
31
+ const metric = (await req.payload.findByID({
32
+ collection: collectionOf('metrics'),
33
+ id: metricId,
34
+ depth: 0,
35
+ overrideAccess: true,
36
+ req,
37
+ select: { kind: true, name: true, slug: true },
38
+ }));
39
+ if (metric && (metric.kind ?? 'stored') === 'computed') {
40
+ return 'Computed metrics (for example Days) cannot be changed with a metric delta. Use a stored metric such as Points.';
41
+ }
29
42
  return true;
30
43
  };
31
44
  export const validateChange = async (value, { data, req }) => {
@@ -1 +1 @@
1
- {"version":3,"file":"ensureTypeRequirements.d.ts","sourceRoot":"","sources":["../../../../src/collections/Logs/hooks/ensureTypeRequirements.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAA;AAS3D,eAAO,MAAM,sBAAsB,EAAE,4BAkCpC,CAAA"}
1
+ {"version":3,"file":"ensureTypeRequirements.d.ts","sourceRoot":"","sources":["../../../../src/collections/Logs/hooks/ensureTypeRequirements.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAA;AAU3D,eAAO,MAAM,sBAAsB,EAAE,4BAmDpC,CAAA"}
@@ -1,4 +1,5 @@
1
1
  import { APIError } from 'payload';
2
+ import { collectionOf } from '../../../collections/helpers.js';
2
3
  import { eventTypeRequiresActor, eventTypeRequiresMetric, } from '../../../services/achievement/eventTypeRequiresActor.js';
3
4
  import { relationId } from '../../../services/achievement/relationId.js';
4
5
  export const ensureTypeRequirements = async ({ data, req }) => {
@@ -23,13 +24,25 @@ export const ensureTypeRequirements = async ({ data, req }) => {
23
24
  throw new APIError('This log type needs an actor (who caused it).', 400);
24
25
  }
25
26
  if (flags.requiresMetric) {
26
- if (!relationId(data.metric)) {
27
+ const metricId = relationId(data.metric);
28
+ if (!metricId) {
27
29
  throw new APIError('This log type needs a metric.', 400);
28
30
  }
29
31
  const change = data.change;
30
32
  if (typeof change !== 'number' || Number.isNaN(change)) {
31
33
  throw new APIError('This log type needs a change amount.', 400);
32
34
  }
35
+ const metric = (await req.payload.findByID({
36
+ collection: collectionOf('metrics'),
37
+ id: metricId,
38
+ depth: 0,
39
+ overrideAccess: true,
40
+ req,
41
+ select: { kind: true },
42
+ }));
43
+ if (metric && (metric.kind ?? 'stored') === 'computed') {
44
+ throw new APIError('Computed metrics cannot be changed with a metric delta. Use a stored metric such as Points.', 400);
45
+ }
33
46
  }
34
47
  return data;
35
48
  };
@@ -1,4 +1,4 @@
1
- import { catalogFilterOptions } from '../fields/catalogFilter.js';
1
+ import { catalogFilterOptions, metricLogFilterOptions } from '../fields/catalogFilter.js';
2
2
  import { collectionAdmin, collectionOf, optionalFields, slugOf } from '../helpers.js';
3
3
  import { getAchievementOptions } from '../../options-store.js';
4
4
  import { scopeField, userField } from '../fields/index.js';
@@ -58,11 +58,11 @@ export function buildLogsCollection() {
58
58
  name: 'metric',
59
59
  type: 'relationship',
60
60
  relationTo: collectionOf('metrics'),
61
- filterOptions: catalogFilterOptions,
61
+ filterOptions: metricLogFilterOptions,
62
62
  validate: validateMetric,
63
63
  admin: {
64
64
  width: '50%',
65
- description: 'Which score moved.',
65
+ description: 'Which stored score moved (computed metrics like Days cannot be chosen here).',
66
66
  components: {
67
67
  Field: {
68
68
  path: '@dappermountain/payload-plugin-achievements/client#MetricField',
@@ -1,8 +1,14 @@
1
- import type { Where } from 'payload';
1
+ import type { FilterOptions, Where } from 'payload';
2
2
  /** Relationship picker: same scope as the parent doc, or unscoped (global) catalog rows. */
3
3
  export declare function catalogFilterOptions({ data }: {
4
4
  data?: {
5
5
  scope?: unknown;
6
6
  };
7
7
  }): Where | true;
8
+ /**
9
+ * Metric picker on achievement logs: same scope rules as other catalog fields,
10
+ * but when the log type needs a metric (e.g. `metric.delta`), only **stored**
11
+ * metrics are offered — computed metrics (like Days) cannot receive deltas.
12
+ */
13
+ export declare const metricLogFilterOptions: FilterOptions;
8
14
  //# sourceMappingURL=catalogFilter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"catalogFilter.d.ts","sourceRoot":"","sources":["../../../src/collections/fields/catalogFilter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAIpC,4FAA4F;AAC5F,wBAAgB,oBAAoB,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GAAG,KAAK,GAAG,IAAI,CAM3F"}
1
+ {"version":3,"file":"catalogFilter.d.ts","sourceRoot":"","sources":["../../../src/collections/fields/catalogFilter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAKnD,4FAA4F;AAC5F,wBAAgB,oBAAoB,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GAAG,KAAK,GAAG,IAAI,CAM3F;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,aAqBpC,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { eventTypeRequiresMetric } from '../../services/achievement/eventTypeRequiresActor.js';
1
2
  import { relationId } from '../../services/achievement/relationId.js';
2
3
  /** Relationship picker: same scope as the parent doc, or unscoped (global) catalog rows. */
3
4
  export function catalogFilterOptions({ data }) {
@@ -8,3 +9,28 @@ export function catalogFilterOptions({ data }) {
8
9
  or: [{ scope: { equals: scopeId } }, { scope: { exists: false } }, { scope: { equals: null } }],
9
10
  };
10
11
  }
12
+ /**
13
+ * Metric picker on achievement logs: same scope rules as other catalog fields,
14
+ * but when the log type needs a metric (e.g. `metric.delta`), only **stored**
15
+ * metrics are offered — computed metrics (like Days) cannot receive deltas.
16
+ */
17
+ export const metricLogFilterOptions = async ({ data, req }) => {
18
+ const scopeFilter = catalogFilterOptions({ data: data });
19
+ const typeId = relationId(data?.type);
20
+ let storedOnly = false;
21
+ if (typeId && req?.payload) {
22
+ storedOnly = await eventTypeRequiresMetric({
23
+ payload: req.payload,
24
+ req,
25
+ typeIdOrDoc: typeId,
26
+ });
27
+ }
28
+ if (!storedOnly)
29
+ return scopeFilter;
30
+ const storedWhere = {
31
+ or: [{ kind: { equals: 'stored' } }, { kind: { exists: false } }, { kind: { equals: null } }],
32
+ };
33
+ if (scopeFilter === true)
34
+ return storedWhere;
35
+ return { and: [scopeFilter, storedWhere] };
36
+ };
@@ -1,4 +1,9 @@
1
1
  import type { PayloadRequest } from 'payload';
2
+ /**
3
+ * Ensure a tier request exists when unlock rules pass.
4
+ * Prefer open (pending/approved) rows. If only a **rejected** request exists,
5
+ * leave it alone — do not auto-queue a new pending after a denial.
6
+ */
2
7
  export declare function ensureTierRequest(args: {
3
8
  req: PayloadRequest;
4
9
  userId: string;
@@ -9,7 +14,8 @@ export declare function ensureTierRequest(args: {
9
14
  created: boolean;
10
15
  }>;
11
16
  /**
12
- * When unlock rules pass and the tier requires review, ensure a pending/approved request exists.
17
+ * When unlock rules pass and the tier requires review, ensure a request exists.
18
+ * Does not recreate a request after rejection — reopen by updating that row or creating manually.
13
19
  */
14
20
  export declare function syncTierProgression(args: {
15
21
  req: PayloadRequest;
@@ -1 +1 @@
1
- {"version":3,"file":"tierProgression.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/tierProgression.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAS,MAAM,SAAS,CAAA;AAgBpD,wBAAsB,iBAAiB,CAAC,IAAI,EAAE;IAC5C,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB,GAAG,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAsC9D;AAED;;GAEG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE;IAC9C,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB,GAAG,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,CA0DjC;AAED,OAAO,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAA"}
1
+ {"version":3,"file":"tierProgression.d.ts","sourceRoot":"","sources":["../../../src/services/achievement/tierProgression.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAS,MAAM,SAAS,CAAA;AAqBpD;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE;IAC5C,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB,GAAG,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAqD9D;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE;IAC9C,GAAG,EAAE,cAAc,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB,GAAG,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,CA0DjC;AAED,OAAO,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAA"}
@@ -2,26 +2,48 @@ import { collectionOf } from '../../collections/helpers.js';
2
2
  import { CTX_SYSTEM_REQUEST } from './contextFlags.js';
3
3
  import { evaluateRules } from './evaluateRules.js';
4
4
  import { hasApprovedTierRequest } from './tierApproval.js';
5
+ function tierRequestScopeClauses(scopeId) {
6
+ if (scopeId)
7
+ return [{ scope: { equals: scopeId } }];
8
+ return [{ scope: { exists: false } }];
9
+ }
10
+ /**
11
+ * Ensure a tier request exists when unlock rules pass.
12
+ * Prefer open (pending/approved) rows. If only a **rejected** request exists,
13
+ * leave it alone — do not auto-queue a new pending after a denial.
14
+ */
5
15
  export async function ensureTierRequest(args) {
6
- const clauses = [
16
+ const base = [
7
17
  { user: { equals: args.userId } },
8
18
  { tier: { equals: args.tierId } },
9
- { status: { in: ['pending', 'approved'] } },
19
+ ...tierRequestScopeClauses(args.scopeId),
10
20
  ];
11
- if (args.scopeId)
12
- clauses.push({ scope: { equals: args.scopeId } });
13
- else
14
- clauses.push({ scope: { exists: false } });
15
- const existing = await args.req.payload.find({
21
+ const open = await args.req.payload.find({
22
+ collection: collectionOf('tierRequests'),
23
+ depth: 0,
24
+ limit: 1,
25
+ overrideAccess: true,
26
+ req: args.req,
27
+ where: {
28
+ and: [...base, { status: { in: ['pending', 'approved'] } }],
29
+ },
30
+ });
31
+ if (open.docs[0]) {
32
+ return { doc: open.docs[0], created: false };
33
+ }
34
+ const rejected = await args.req.payload.find({
16
35
  collection: collectionOf('tierRequests'),
17
36
  depth: 0,
18
37
  limit: 1,
19
38
  overrideAccess: true,
20
39
  req: args.req,
21
- where: { and: clauses },
40
+ sort: '-updatedAt',
41
+ where: {
42
+ and: [...base, { status: { equals: 'rejected' } }],
43
+ },
22
44
  });
23
- if (existing.docs[0]) {
24
- return { doc: existing.docs[0], created: false };
45
+ if (rejected.docs[0]) {
46
+ return { doc: rejected.docs[0], created: false };
25
47
  }
26
48
  args.req.context[CTX_SYSTEM_REQUEST] = true;
27
49
  try {
@@ -43,7 +65,8 @@ export async function ensureTierRequest(args) {
43
65
  }
44
66
  }
45
67
  /**
46
- * When unlock rules pass and the tier requires review, ensure a pending/approved request exists.
68
+ * When unlock rules pass and the tier requires review, ensure a request exists.
69
+ * Does not recreate a request after rejection — reopen by updating that row or creating manually.
47
70
  */
48
71
  export async function syncTierProgression(args) {
49
72
  const scopeId = args.scopeId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dappermountain/payload-plugin-achievements",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
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",