@dappermountain/payload-plugin-achievements 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +319 -0
- package/dist/access/helpers/index.d.ts +9 -0
- package/dist/access/helpers/index.d.ts.map +1 -0
- package/dist/access/helpers/index.js +25 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +2 -0
- package/dist/access/roles/index.d.ts +15 -0
- package/dist/access/roles/index.d.ts.map +1 -0
- package/dist/access/roles/index.js +55 -0
- package/dist/admin/ActorField.d.ts +9 -0
- package/dist/admin/ActorField.d.ts.map +1 -0
- package/dist/admin/ActorField.js +19 -0
- package/dist/admin/MetricChangeField.d.ts +9 -0
- package/dist/admin/MetricChangeField.d.ts.map +1 -0
- package/dist/admin/MetricChangeField.js +21 -0
- package/dist/admin/MetricField.d.ts +9 -0
- package/dist/admin/MetricField.d.ts.map +1 -0
- package/dist/admin/MetricField.js +23 -0
- package/dist/admin/useEventTypeFlags.d.ts +7 -0
- package/dist/admin/useEventTypeFlags.d.ts.map +1 -0
- package/dist/admin/useEventTypeFlags.js +68 -0
- package/dist/catalog.d.ts +21 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +9 -0
- package/dist/collections/AchievementRequests/access/index.d.ts +3 -0
- package/dist/collections/AchievementRequests/access/index.d.ts.map +1 -0
- package/dist/collections/AchievementRequests/access/index.js +8 -0
- package/dist/collections/AchievementRequests/hooks/index.d.ts +3 -0
- package/dist/collections/AchievementRequests/hooks/index.d.ts.map +1 -0
- package/dist/collections/AchievementRequests/hooks/index.js +5 -0
- package/dist/collections/AchievementRequests/hooks/prepareAchievementRequest.d.ts +12 -0
- package/dist/collections/AchievementRequests/hooks/prepareAchievementRequest.d.ts.map +1 -0
- package/dist/collections/AchievementRequests/hooks/prepareAchievementRequest.js +105 -0
- package/dist/collections/AchievementRequests/index.d.ts +3 -0
- package/dist/collections/AchievementRequests/index.d.ts.map +1 -0
- package/dist/collections/AchievementRequests/index.js +39 -0
- package/dist/collections/Achievements/access/index.d.ts +3 -0
- package/dist/collections/Achievements/access/index.d.ts.map +1 -0
- package/dist/collections/Achievements/access/index.js +2 -0
- package/dist/collections/Achievements/hooks/index.d.ts +3 -0
- package/dist/collections/Achievements/hooks/index.d.ts.map +1 -0
- package/dist/collections/Achievements/hooks/index.js +4 -0
- package/dist/collections/Achievements/hooks/validateEligibilityRules.d.ts +3 -0
- package/dist/collections/Achievements/hooks/validateEligibilityRules.d.ts.map +1 -0
- package/dist/collections/Achievements/hooks/validateEligibilityRules.js +8 -0
- package/dist/collections/Achievements/index.d.ts +3 -0
- package/dist/collections/Achievements/index.d.ts.map +1 -0
- package/dist/collections/Achievements/index.js +62 -0
- package/dist/collections/EventTypes/access/index.d.ts +12 -0
- package/dist/collections/EventTypes/access/index.d.ts.map +1 -0
- package/dist/collections/EventTypes/access/index.js +2 -0
- package/dist/collections/EventTypes/hooks/index.d.ts +3 -0
- package/dist/collections/EventTypes/hooks/index.d.ts.map +1 -0
- package/dist/collections/EventTypes/hooks/index.js +6 -0
- package/dist/collections/EventTypes/index.d.ts +3 -0
- package/dist/collections/EventTypes/index.d.ts.map +1 -0
- package/dist/collections/EventTypes/index.js +67 -0
- package/dist/collections/Grants/access/index.d.ts +3 -0
- package/dist/collections/Grants/access/index.d.ts.map +1 -0
- package/dist/collections/Grants/access/index.js +8 -0
- package/dist/collections/Grants/hooks/ensureUniqueGrant.d.ts +4 -0
- package/dist/collections/Grants/hooks/ensureUniqueGrant.d.ts.map +1 -0
- package/dist/collections/Grants/hooks/ensureUniqueGrant.js +34 -0
- package/dist/collections/Grants/hooks/index.d.ts +3 -0
- package/dist/collections/Grants/hooks/index.d.ts.map +1 -0
- package/dist/collections/Grants/hooks/index.js +7 -0
- package/dist/collections/Grants/hooks/setGrantTitle.d.ts +4 -0
- package/dist/collections/Grants/hooks/setGrantTitle.d.ts.map +1 -0
- package/dist/collections/Grants/hooks/setGrantTitle.js +47 -0
- package/dist/collections/Grants/index.d.ts +3 -0
- package/dist/collections/Grants/index.d.ts.map +1 -0
- package/dist/collections/Grants/index.js +62 -0
- package/dist/collections/Logs/access/index.d.ts +3 -0
- package/dist/collections/Logs/access/index.d.ts.map +1 -0
- package/dist/collections/Logs/access/index.js +8 -0
- package/dist/collections/Logs/fields/validators.d.ts +5 -0
- package/dist/collections/Logs/fields/validators.d.ts.map +1 -0
- package/dist/collections/Logs/fields/validators.js +44 -0
- package/dist/collections/Logs/hooks/ensureTypeRequirements.d.ts +3 -0
- package/dist/collections/Logs/hooks/ensureTypeRequirements.d.ts.map +1 -0
- package/dist/collections/Logs/hooks/ensureTypeRequirements.js +35 -0
- package/dist/collections/Logs/hooks/index.d.ts +3 -0
- package/dist/collections/Logs/hooks/index.d.ts.map +1 -0
- package/dist/collections/Logs/hooks/index.js +4 -0
- package/dist/collections/Logs/index.d.ts +4 -0
- package/dist/collections/Logs/index.d.ts.map +1 -0
- package/dist/collections/Logs/index.js +113 -0
- package/dist/collections/Metrics/access/index.d.ts +12 -0
- package/dist/collections/Metrics/access/index.d.ts.map +1 -0
- package/dist/collections/Metrics/access/index.js +2 -0
- package/dist/collections/Metrics/hooks/index.d.ts +3 -0
- package/dist/collections/Metrics/hooks/index.d.ts.map +1 -0
- package/dist/collections/Metrics/hooks/index.js +6 -0
- package/dist/collections/Metrics/index.d.ts +3 -0
- package/dist/collections/Metrics/index.d.ts.map +1 -0
- package/dist/collections/Metrics/index.js +35 -0
- package/dist/collections/Tiers/access/index.d.ts +3 -0
- package/dist/collections/Tiers/access/index.d.ts.map +1 -0
- package/dist/collections/Tiers/access/index.js +2 -0
- package/dist/collections/Tiers/hooks/index.d.ts +3 -0
- package/dist/collections/Tiers/hooks/index.d.ts.map +1 -0
- package/dist/collections/Tiers/hooks/index.js +4 -0
- package/dist/collections/Tiers/hooks/validateUnlockRules.d.ts +3 -0
- package/dist/collections/Tiers/hooks/validateUnlockRules.d.ts.map +1 -0
- package/dist/collections/Tiers/hooks/validateUnlockRules.js +7 -0
- package/dist/collections/Tiers/index.d.ts +3 -0
- package/dist/collections/Tiers/index.d.ts.map +1 -0
- package/dist/collections/Tiers/index.js +81 -0
- package/dist/collections/access/definitions.d.ts +5 -0
- package/dist/collections/access/definitions.d.ts.map +1 -0
- package/dist/collections/access/definitions.js +11 -0
- package/dist/collections/applyOverrides.d.ts +9 -0
- package/dist/collections/applyOverrides.d.ts.map +1 -0
- package/dist/collections/applyOverrides.js +34 -0
- package/dist/collections/fields/catalogFilter.d.ts +8 -0
- package/dist/collections/fields/catalogFilter.d.ts.map +1 -0
- package/dist/collections/fields/catalogFilter.js +10 -0
- package/dist/collections/fields/index.d.ts +6 -0
- package/dist/collections/fields/index.d.ts.map +1 -0
- package/dist/collections/fields/index.js +5 -0
- package/dist/collections/fields/localizedText.d.ts +10 -0
- package/dist/collections/fields/localizedText.d.ts.map +1 -0
- package/dist/collections/fields/localizedText.js +20 -0
- package/dist/collections/fields/ruleFields.d.ts +8 -0
- package/dist/collections/fields/ruleFields.d.ts.map +1 -0
- package/dist/collections/fields/ruleFields.js +123 -0
- package/dist/collections/fields/scopeField.d.ts +7 -0
- package/dist/collections/fields/scopeField.d.ts.map +1 -0
- package/dist/collections/fields/scopeField.js +22 -0
- package/dist/collections/fields/userField.d.ts +4 -0
- package/dist/collections/fields/userField.d.ts.map +1 -0
- package/dist/collections/fields/userField.js +12 -0
- package/dist/collections/fields/usersAchievementsGroup.d.ts +8 -0
- package/dist/collections/fields/usersAchievementsGroup.d.ts.map +1 -0
- package/dist/collections/fields/usersAchievementsGroup.js +36 -0
- package/dist/collections/helpers.d.ts +16 -0
- package/dist/collections/helpers.d.ts.map +1 -0
- package/dist/collections/helpers.js +31 -0
- package/dist/collections/hooks/eachRuleLeaf.d.ts +3 -0
- package/dist/collections/hooks/eachRuleLeaf.d.ts.map +1 -0
- package/dist/collections/hooks/eachRuleLeaf.js +20 -0
- package/dist/collections/hooks/index.d.ts +4 -0
- package/dist/collections/hooks/index.d.ts.map +1 -0
- package/dist/collections/hooks/index.js +3 -0
- package/dist/collections/hooks/lockSystemCatalogFields.d.ts +7 -0
- package/dist/collections/hooks/lockSystemCatalogFields.d.ts.map +1 -0
- package/dist/collections/hooks/lockSystemCatalogFields.js +15 -0
- package/dist/collections/hooks/preventCatalogDelete.d.ts +5 -0
- package/dist/collections/hooks/preventCatalogDelete.d.ts.map +1 -0
- package/dist/collections/hooks/preventCatalogDelete.js +89 -0
- package/dist/collections/index.d.ts +4 -0
- package/dist/collections/index.d.ts.map +1 -0
- package/dist/collections/index.js +23 -0
- package/dist/collections/slugs.d.ts +22 -0
- package/dist/collections/slugs.d.ts.map +1 -0
- package/dist/collections/slugs.js +51 -0
- package/dist/defaults.d.ts +21 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +31 -0
- package/dist/enabled.d.ts +4 -0
- package/dist/enabled.d.ts.map +1 -0
- package/dist/enabled.js +9 -0
- package/dist/endpoints/index.d.ts +4 -0
- package/dist/endpoints/index.d.ts.map +1 -0
- package/dist/endpoints/index.js +6 -0
- package/dist/endpoints/me.d.ts +7 -0
- package/dist/endpoints/me.d.ts.map +1 -0
- package/dist/endpoints/me.js +50 -0
- package/dist/exports/client.d.ts +4 -0
- package/dist/exports/client.d.ts.map +1 -0
- package/dist/exports/client.js +3 -0
- package/dist/extensions/registry.d.ts +3 -0
- package/dist/extensions/registry.d.ts.map +1 -0
- package/dist/extensions/registry.js +12 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/options-store.d.ts +7 -0
- package/dist/options-store.d.ts.map +1 -0
- package/dist/options-store.js +15 -0
- package/dist/plugin.d.ts +8 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +31 -0
- package/dist/seed/index.d.ts +63 -0
- package/dist/seed/index.d.ts.map +1 -0
- package/dist/seed/index.js +375 -0
- package/dist/services/achievement/buildCatalogProgressGroups.d.ts +37 -0
- package/dist/services/achievement/buildCatalogProgressGroups.d.ts.map +1 -0
- package/dist/services/achievement/buildCatalogProgressGroups.js +70 -0
- package/dist/services/achievement/builtInRules.d.ts +3 -0
- package/dist/services/achievement/builtInRules.d.ts.map +1 -0
- package/dist/services/achievement/builtInRules.js +280 -0
- package/dist/services/achievement/evaluateRules.d.ts +48 -0
- package/dist/services/achievement/evaluateRules.d.ts.map +1 -0
- package/dist/services/achievement/evaluateRules.js +206 -0
- package/dist/services/achievement/eventTypeRequiresActor.d.ts +24 -0
- package/dist/services/achievement/eventTypeRequiresActor.d.ts.map +1 -0
- package/dist/services/achievement/eventTypeRequiresActor.js +46 -0
- package/dist/services/achievement/getUserProgress.d.ts +53 -0
- package/dist/services/achievement/getUserProgress.d.ts.map +1 -0
- package/dist/services/achievement/getUserProgress.js +163 -0
- package/dist/services/achievement/index.d.ts +14 -0
- package/dist/services/achievement/index.d.ts.map +1 -0
- package/dist/services/achievement/index.js +9 -0
- package/dist/services/achievement/recordEvent.d.ts +41 -0
- package/dist/services/achievement/recordEvent.d.ts.map +1 -0
- package/dist/services/achievement/recordEvent.js +126 -0
- package/dist/services/achievement/relationId.d.ts +3 -0
- package/dist/services/achievement/relationId.d.ts.map +1 -0
- package/dist/services/achievement/relationId.js +15 -0
- package/dist/services/achievement/resolveCatalog.d.ts +14 -0
- package/dist/services/achievement/resolveCatalog.d.ts.map +1 -0
- package/dist/services/achievement/resolveCatalog.js +34 -0
- package/dist/services/achievement/resolveCurrentTier.d.ts +21 -0
- package/dist/services/achievement/resolveCurrentTier.d.ts.map +1 -0
- package/dist/services/achievement/resolveCurrentTier.js +51 -0
- package/dist/services/achievement/resolveTierProgress.d.ts +35 -0
- package/dist/services/achievement/resolveTierProgress.d.ts.map +1 -0
- package/dist/services/achievement/resolveTierProgress.js +99 -0
- package/dist/services/achievement/reviewAchievementRequest.d.ts +11 -0
- package/dist/services/achievement/reviewAchievementRequest.d.ts.map +1 -0
- package/dist/services/achievement/reviewAchievementRequest.js +37 -0
- package/dist/services/achievement/submitAchievementRequest.d.ts +21 -0
- package/dist/services/achievement/submitAchievementRequest.d.ts.map +1 -0
- package/dist/services/achievement/submitAchievementRequest.js +126 -0
- package/dist/services/achievement/validateRules.d.ts +11 -0
- package/dist/services/achievement/validateRules.d.ts.map +1 -0
- package/dist/services/achievement/validateRules.js +74 -0
- package/dist/services/achievement/where.d.ts +9 -0
- package/dist/services/achievement/where.d.ts.map +1 -0
- package/dist/services/achievement/where.js +23 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Dapper Mountain
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# `@dappermountain/payload-plugin-achievements`
|
|
2
|
+
|
|
3
|
+
A Payload CMS plugin for a **rule-based achievements and ranking system** — tiers, badges, points, grants, review workflows, and an append-only activity log — configured in Admin, not hard-coded in your app.
|
|
4
|
+
|
|
5
|
+
Most gamification features end up as one-off collections and scattered hooks. This plugin gives you a reusable engine: define *what* can be earned and *how* it unlocks in Payload Admin, then record events from your application code. Current tier and progress are **derived** from rules and history, so you don’t maintain a separate progress table that drifts out of sync.
|
|
6
|
+
|
|
7
|
+
## Why this plugin
|
|
8
|
+
|
|
9
|
+
- **Admin-owned catalog** — Admins create achievements, tiers, metrics, and event types in Payload without a deploy.
|
|
10
|
+
- **Composable rules** — Nested AND/OR trees with built-in leaf types (`tier-at-least`, `achievement-complete`, `metric-minimum`, `event-count`) plus host-defined rule types.
|
|
11
|
+
- **Derived progress** — Ladder position and next-tier fill come from evaluating the same rules that unlock ranks. No fragile “progress” documents to keep in sync.
|
|
12
|
+
- **Request → review → grant** — Users can request achievements; reviewers approve or reject; composed achievements can auto-grant when completion rules pass.
|
|
13
|
+
- **Append-only history** — Logs capture grants, metric deltas, tier changes, and custom event kinds for audits and analytics.
|
|
14
|
+
- **Multi-tenant ready** — Optional `scope` (tenant / workspace / org) on catalog rows and user progress.
|
|
15
|
+
- **Host-friendly** — Prefixable collection slugs, Form Builder–style overrides, injectable review policy, and server helpers for recording events from hooks and jobs.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Payload `^3.88`
|
|
20
|
+
- `@payloadcms/ui` `^3.88`
|
|
21
|
+
- React `^19`
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @dappermountain/payload-plugin-achievements
|
|
27
|
+
# or: pnpm add / yarn add / bun add
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { buildConfig } from 'payload'
|
|
34
|
+
import { achievementPlugin } from '@dappermountain/payload-plugin-achievements'
|
|
35
|
+
|
|
36
|
+
export default buildConfig({
|
|
37
|
+
// ...
|
|
38
|
+
plugins: [
|
|
39
|
+
achievementPlugin({
|
|
40
|
+
usersCollectionSlug: 'users',
|
|
41
|
+
canReview: (user, scopeId) => {
|
|
42
|
+
// Return true when this user may edit catalogs and approve requests
|
|
43
|
+
// for the given scope (or null for unscoped / global).
|
|
44
|
+
return Boolean(user?.roles?.includes('admin'))
|
|
45
|
+
},
|
|
46
|
+
// Optional: scope catalog + progress to a tenant collection
|
|
47
|
+
// scope: { collection: 'tenants', relationField: 'scope' },
|
|
48
|
+
}),
|
|
49
|
+
],
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
After installing, regenerate the Admin import map so conditional log fields resolve:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
payload generate:importmap
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Your app still owns schema and migrations — run `payload migrate` (or your usual migrate flow) as you would for any new collections.
|
|
60
|
+
|
|
61
|
+
Seed the built-in engine catalog (points metric + system event types) once:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { seedAchievementCatalog } from '@dappermountain/payload-plugin-achievements'
|
|
65
|
+
|
|
66
|
+
await seedAchievementCatalog(payload)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## What you get
|
|
70
|
+
|
|
71
|
+
### Collections
|
|
72
|
+
|
|
73
|
+
Slugs are prefixed with `achievement-` by default so they don’t collide with your own collections.
|
|
74
|
+
|
|
75
|
+
| Config key | Default slug | Purpose |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `metrics` | `achievement-metrics` | Named scores (points, streaks, …). Totals are derived by summing `metric.delta` logs. |
|
|
78
|
+
| `eventTypes` | `achievement-event-types` | Kinds of log entries you can count or require. |
|
|
79
|
+
| `tiers` | `achievement-tiers` | Ordered ladder steps with unlock rules. |
|
|
80
|
+
| `achievements` | `achievement-definitions` | Things a user can earn (with eligibility / completion rules). |
|
|
81
|
+
| `grants` | `achievement-grants` | One row per earned achievement per user (+ scope). |
|
|
82
|
+
| `achievementRequests` | `achievement-requests` | Request → pending / approved / rejected. |
|
|
83
|
+
| `logs` | `achievement-logs` | Append-only history of what happened. |
|
|
84
|
+
|
|
85
|
+
**System catalog rows** (seeded by `seedAchievementCatalog`) are protected from deletion and slug changes:
|
|
86
|
+
|
|
87
|
+
| Slug | Role |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `points` | Default metric |
|
|
90
|
+
| `metric.delta` | Metric change log type (requires metric + change amount) |
|
|
91
|
+
| `achievement.granted` | Written when a grant is created |
|
|
92
|
+
| `tier.changed` | Written when derived tier moves |
|
|
93
|
+
|
|
94
|
+
Event types can require an **actor** and/or a **metric + change**. Admin only prompts for those fields when the type needs them.
|
|
95
|
+
|
|
96
|
+
### Rules engine
|
|
97
|
+
|
|
98
|
+
Unlock (tiers), eligibility, and completion (achievements) share the same rule tree shape:
|
|
99
|
+
|
|
100
|
+
- Top-level empty list → everyone passes
|
|
101
|
+
- Nested groups → AND (“all of these”) or OR (“any of these”)
|
|
102
|
+
- Built-in leaves → `tier-at-least`, `achievement-complete`, `metric-minimum`, `event-count`
|
|
103
|
+
- Custom leaves → register via `extensions.ruleTypes`
|
|
104
|
+
|
|
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`):
|
|
106
|
+
|
|
107
|
+
| Combinator / leaf | Progress behavior |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| AND | Equal average of *requirement* children |
|
|
110
|
+
| OR | Best child (max) |
|
|
111
|
+
| `tier-at-least` | Gate — skipped in AND averages |
|
|
112
|
+
| `event-count` / `metric-minimum` | Fraction of the target |
|
|
113
|
+
| `achievement-complete` | `1` if granted; otherwise rolls up that achievement’s completion (or eligibility) rules |
|
|
114
|
+
|
|
115
|
+
**Eligibility rules** gate who may request an achievement. **Completion rules** define what a composed achievement is made of. When `requiresReview` is false and completion rules pass, the parent can auto-grant. Cycles in nested achievement graphs are ignored.
|
|
116
|
+
|
|
117
|
+
### Access model
|
|
118
|
+
|
|
119
|
+
- **`canReview`** — Required for privileged writes (catalogs, grants, logs, approving requests). If omitted, those writes are denied.
|
|
120
|
+
- User requests still go through normal collection access and eligibility hooks.
|
|
121
|
+
- Server helpers (`recordLog`, `recordMetricChange`, `grantAchievement`, `resolveCurrentTier`, …) run with `overrideAccess: true`. Call them only from trusted server code (hooks, jobs, locked-down endpoints).
|
|
122
|
+
|
|
123
|
+
### Localization
|
|
124
|
+
|
|
125
|
+
When your Payload config enables **`localization`**, catalog display fields are already `localized: true`:
|
|
126
|
+
|
|
127
|
+
| Collection | Localized | Not localized |
|
|
128
|
+
| --- | --- | --- |
|
|
129
|
+
| Achievements / tiers | `name`, `description` | `slug`, rules, flags |
|
|
130
|
+
| Metrics / event types | `name` | `slug`, system flags |
|
|
131
|
+
|
|
132
|
+
UI chrome and validation messages ship in English. Application UI copy stays in your app’s i18n layer — the plugin does not inject host translation dictionaries. Seed extra locales yourself after the English system catalog is in place.
|
|
133
|
+
|
|
134
|
+
## Configuration reference
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
achievementPlugin({
|
|
138
|
+
enabled?: boolean
|
|
139
|
+
usersCollectionSlug?: string
|
|
140
|
+
canReview?: (user, scopeId) => boolean | Promise<boolean>
|
|
141
|
+
scope?: { collection: string; relationField?: string }
|
|
142
|
+
mediaCollection?: string
|
|
143
|
+
slugPrefix?: string // deprecated — prefer collections.prefix
|
|
144
|
+
collections?: {
|
|
145
|
+
prefix?: string
|
|
146
|
+
adminGroup?: string | false
|
|
147
|
+
slugs?: Partial<Record<AchievementCollectionKey, string>>
|
|
148
|
+
overrides?: Partial<Record<AchievementCollectionKey, AchievementCollectionOverride>>
|
|
149
|
+
}
|
|
150
|
+
users?: { includeJoins?: boolean }
|
|
151
|
+
endpoints?: { me?: string | false }
|
|
152
|
+
extensions?: { ruleTypes?: AchievementRuleType[] }
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Core options
|
|
157
|
+
|
|
158
|
+
| Option | Default | Notes |
|
|
159
|
+
| --- | --- | --- |
|
|
160
|
+
| `enabled` | `true` | Set `false` to leave the plugin installed but inactive (no collections / endpoints merged). |
|
|
161
|
+
| `usersCollectionSlug` | `'users'` | Users collection the plugin relates to (grants, requests, joins). |
|
|
162
|
+
| `canReview` | _(denied)_ | **Important.** Without this, users cannot manage catalogs or approve requests. Receives `(user, scopeId)` where `scopeId` may be `null`. |
|
|
163
|
+
| `scope` | _(none)_ | Multi-tenant hook-up. `collection` is the relation target (e.g. `'tenants'`). `relationField` defaults to `'scope'`. Adds an optional scope field across plugin collections. |
|
|
164
|
+
| `mediaCollection` | _(none)_ | When set (e.g. `'media'`), tiers get an optional upload field for ladder badge images. Icon string keys still work without this. |
|
|
165
|
+
|
|
166
|
+
### `collections`
|
|
167
|
+
|
|
168
|
+
| Option | Default | Notes |
|
|
169
|
+
| --- | --- | --- |
|
|
170
|
+
| `prefix` | `'achievement'` | Prefix for default slugs → REST paths and DB table names (`achievement-tiers`, …). |
|
|
171
|
+
| `adminGroup` | `'Achievements'` | Admin sidebar group. Pass `false` to leave collections ungrouped. |
|
|
172
|
+
| `slugs` | _(derived)_ | Absolute slug overrides per collection key (ignores `prefix` for that key). |
|
|
173
|
+
| `overrides` | _(none)_ | Per-collection patches: `access`, `admin`, `hooks`, `labels`. Access keys replace; hook arrays **append after** plugin hooks; `admin` / `labels` merge shallowly. |
|
|
174
|
+
|
|
175
|
+
Example override (notify after a grant is created via request approval):
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
achievementPlugin({
|
|
179
|
+
canReview,
|
|
180
|
+
collections: {
|
|
181
|
+
overrides: {
|
|
182
|
+
achievementRequests: {
|
|
183
|
+
hooks: {
|
|
184
|
+
afterChange: [
|
|
185
|
+
async ({ doc, previousDoc }) => {
|
|
186
|
+
if (doc.status === 'approved' && previousDoc?.status !== 'approved') {
|
|
187
|
+
// notify, analytics, …
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### `users`
|
|
199
|
+
|
|
200
|
+
| Option | Default | Notes |
|
|
201
|
+
| --- | --- | --- |
|
|
202
|
+
| `includeJoins` | `true` | Injects an `achievements` group on the users collection with joins to grants and requests. Set `false` if you own that UI yourself. |
|
|
203
|
+
|
|
204
|
+
### `endpoints`
|
|
205
|
+
|
|
206
|
+
| Option | Default | Notes |
|
|
207
|
+
| --- | --- | --- |
|
|
208
|
+
| `me` | `'/achievements/me'` | Registers `GET /api/achievements/me` for the signed-in user’s grants snapshot (+ derived tiers and lean requests). Pass `false` to skip. Having review access does **not** widen this endpoint to other users. |
|
|
209
|
+
|
|
210
|
+
Query params: `scope`, `limit`, `page`, `requestsLimit`.
|
|
211
|
+
|
|
212
|
+
### `extensions.ruleTypes`
|
|
213
|
+
|
|
214
|
+
Register custom rule evaluators merged with the built-ins:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
achievementPlugin({
|
|
218
|
+
canReview,
|
|
219
|
+
extensions: {
|
|
220
|
+
ruleTypes: [
|
|
221
|
+
{
|
|
222
|
+
type: 'host.custom-check',
|
|
223
|
+
evaluate: async ({ rule, userId, payload, req }) => {
|
|
224
|
+
// return true when the rule passes
|
|
225
|
+
return true
|
|
226
|
+
},
|
|
227
|
+
// optional: progress: async () => 0.5,
|
|
228
|
+
// optional: progressRole: 'requirement' | 'gate',
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Server helpers
|
|
236
|
+
|
|
237
|
+
Use these from hooks, jobs, and trusted endpoints — not from the public client:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
import {
|
|
241
|
+
recordLog,
|
|
242
|
+
recordMetricChange,
|
|
243
|
+
grantAchievement,
|
|
244
|
+
resolveCurrentTier,
|
|
245
|
+
resolveTierProgress,
|
|
246
|
+
getUserProgress,
|
|
247
|
+
submitAchievementRequest,
|
|
248
|
+
reviewAchievementRequest,
|
|
249
|
+
} from '@dappermountain/payload-plugin-achievements'
|
|
250
|
+
|
|
251
|
+
await recordLog({ payload, userId, scopeId, type: 'host.custom-kind', actorId })
|
|
252
|
+
await recordMetricChange({ payload, userId, scopeId, metric: 'points', change: 10 })
|
|
253
|
+
const tier = await resolveCurrentTier({ payload, userId, scopeId })
|
|
254
|
+
```
|
|
255
|
+
|
|
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.
|
|
257
|
+
|
|
258
|
+
## REST surface
|
|
259
|
+
|
|
260
|
+
| Goal | Request |
|
|
261
|
+
| --- | --- |
|
|
262
|
+
| Current user snapshot | `GET /api/achievements/me?scope=<scopeId>&limit=10&page=1` |
|
|
263
|
+
| List grants | `GET /api/achievement-grants?where[user][equals]=<userId>` |
|
|
264
|
+
| Log history | `GET /api/achievement-logs?where[user][equals]=<userId>` |
|
|
265
|
+
| Request an achievement | `POST /api/achievement-requests` with `{ achievement }` |
|
|
266
|
+
| Approve / reject | `PATCH /api/achievement-requests/:id` with `{ status: "approved" \| "rejected" }` |
|
|
267
|
+
| Browse catalogs | `GET /api/achievement-event-types`, `GET /api/achievement-metrics`, … |
|
|
268
|
+
|
|
269
|
+
Exact collection paths follow your `collections.prefix` / `slugs` settings.
|
|
270
|
+
|
|
271
|
+
## Seeding
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import {
|
|
275
|
+
seedAchievementCatalog,
|
|
276
|
+
seedAchievements,
|
|
277
|
+
} from '@dappermountain/payload-plugin-achievements'
|
|
278
|
+
|
|
279
|
+
await seedAchievementCatalog(payload) // system metrics + event types (English)
|
|
280
|
+
|
|
281
|
+
await seedAchievements(payload, {
|
|
282
|
+
eventTypes: [{ name: 'Custom kind', slug: 'host.custom-kind', requiresActor: true }],
|
|
283
|
+
metrics: [{ name: 'Streak days', slug: 'streak-days' }],
|
|
284
|
+
tiers: [
|
|
285
|
+
{
|
|
286
|
+
name: 'Prospect',
|
|
287
|
+
slug: 'prospect',
|
|
288
|
+
rank: 0,
|
|
289
|
+
unlockRules: [], // open
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
achievements: [
|
|
293
|
+
{
|
|
294
|
+
name: 'First steps',
|
|
295
|
+
slug: 'first-steps',
|
|
296
|
+
requiresReview: false,
|
|
297
|
+
completionRules: [{ type: 'metric-minimum', metricSlug: 'points', minimum: 10 }],
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
})
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
`name` / `description` accept a string (default locale) or a locale map (`{ en: '…', es: '…' }`) when Payload localization is enabled. Prefer seeding extra locales in the host after the English system catalog exists.
|
|
304
|
+
|
|
305
|
+
## License
|
|
306
|
+
|
|
307
|
+
MIT © Dapper Mountain
|
|
308
|
+
|
|
309
|
+
## Agent context (Cursor)
|
|
310
|
+
|
|
311
|
+
This repo vendors AI agent rules and skills under [`.agents/`](.agents/). See [`AGENTS.md`](AGENTS.md).
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
bun install
|
|
315
|
+
bun run agents:sync
|
|
316
|
+
bun run skills:install
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Dev dependencies: [`@dappermountain/agent-practices`](https://www.npmjs.com/package/@dappermountain/agent-practices), [`@dappermountain/agent-payload`](https://www.npmjs.com/package/@dappermountain/agent-payload). Run `agents:sync` after install or upgrade so rules land under `.agents/`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Access, AccessArgs, PayloadRequest, Where } from 'payload';
|
|
2
|
+
type AccessResult = boolean | Where;
|
|
3
|
+
export type AccessFn = (args: AccessArgs) => AccessResult | Promise<AccessResult>;
|
|
4
|
+
export declare const allowAll: Access;
|
|
5
|
+
export declare const denyAll: Access;
|
|
6
|
+
export declare function requireOne(...fns: AccessFn[]): Access;
|
|
7
|
+
export declare function getUserId(req: PayloadRequest): string | null;
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/access/helpers/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAExE,KAAK,YAAY,GAAG,OAAO,GAAG,KAAK,CAAA;AAEnC,MAAM,MAAM,QAAQ,GAAG,CAAC,IAAI,EAAE,UAAU,KAAK,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;AAEjF,eAAO,MAAM,QAAQ,EAAE,MAAmB,CAAA;AAE1C,eAAO,MAAM,OAAO,EAAE,MAAoB,CAAA;AAE1C,wBAAgB,UAAU,CAAC,GAAG,GAAG,EAAE,QAAQ,EAAE,GAAG,MAAM,CASrD;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CAO5D"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const allowAll = () => true;
|
|
2
|
+
export const denyAll = () => false;
|
|
3
|
+
export function requireOne(...fns) {
|
|
4
|
+
return async (args) => {
|
|
5
|
+
for (const fn of fns) {
|
|
6
|
+
const result = await fn(args);
|
|
7
|
+
if (result === true)
|
|
8
|
+
return true;
|
|
9
|
+
if (result && typeof result === 'object')
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function getUserId(req) {
|
|
16
|
+
const user = req.user;
|
|
17
|
+
if (!user || typeof user !== 'object')
|
|
18
|
+
return null;
|
|
19
|
+
const id = user.id;
|
|
20
|
+
if (typeof id === 'string' && id)
|
|
21
|
+
return id;
|
|
22
|
+
if (typeof id === 'number' && Number.isFinite(id))
|
|
23
|
+
return String(id);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/access/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AccessArgs } from 'payload';
|
|
2
|
+
import type { AchievementCollectionKey } from '../../types';
|
|
3
|
+
/** Authenticated user may read/write rows tied to themselves. */
|
|
4
|
+
export declare const isSelfUser: (userRelationField?: string) => ({ req, data }: AccessArgs) => boolean | {
|
|
5
|
+
[x: string]: {
|
|
6
|
+
equals: string;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Host `canReview` policy. When unset, privileged writes are denied (safe default).
|
|
11
|
+
* Passes document scope when available (from `data.scope` or loaded by id).
|
|
12
|
+
*/
|
|
13
|
+
export declare const isReviewer: (collectionKey?: AchievementCollectionKey) => (args: AccessArgs) => Promise<boolean>;
|
|
14
|
+
export declare const isAuthenticated: ({ req }: AccessArgs) => boolean;
|
|
15
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/access/roles/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAEzC,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AAM3D,iEAAiE;AACjE,eAAO,MAAM,UAAU,GACpB,0BAA0B,MAC1B,eAAe,UAAU;;;;CAgBzB,CAAA;AA0BH;;;GAGG;AACH,eAAO,MAAM,UAAU,GACpB,gBAAgB,wBAAwB,MAClC,MAAM,UAAU,qBAMtB,CAAA;AAEH,eAAO,MAAM,eAAe,GAAI,SAAS,UAAU,YAAsB,CAAA"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { collectionOf } from '../../collections/helpers';
|
|
2
|
+
import { getAchievementOptions } from '../../options-store';
|
|
3
|
+
import { relationId } from '../../services/achievement/relationId';
|
|
4
|
+
import { getUserId } from '../helpers/index';
|
|
5
|
+
/** Authenticated user may read/write rows tied to themselves. */
|
|
6
|
+
export const isSelfUser = (userRelationField = 'user') => ({ req, data }) => {
|
|
7
|
+
const userId = getUserId(req);
|
|
8
|
+
if (!userId)
|
|
9
|
+
return false;
|
|
10
|
+
if (data && typeof data === 'object' && userRelationField in data) {
|
|
11
|
+
const related = data[userRelationField];
|
|
12
|
+
const relatedId = typeof related === 'object' && related && 'id' in related
|
|
13
|
+
? String(related.id)
|
|
14
|
+
: String(related);
|
|
15
|
+
return relatedId === userId;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
[userRelationField]: { equals: userId },
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
async function scopeIdForAccess(args, collectionKey) {
|
|
22
|
+
const fromData = relationId(args.data?.scope);
|
|
23
|
+
if (fromData)
|
|
24
|
+
return fromData;
|
|
25
|
+
if (!args.id || !collectionKey || !args.req?.payload)
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
const doc = (await args.req.payload.findByID({
|
|
29
|
+
collection: collectionOf(collectionKey),
|
|
30
|
+
id: String(args.id),
|
|
31
|
+
depth: 0,
|
|
32
|
+
overrideAccess: true,
|
|
33
|
+
req: args.req,
|
|
34
|
+
select: { scope: true },
|
|
35
|
+
}));
|
|
36
|
+
return relationId(doc?.scope);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Host `canReview` policy. When unset, privileged writes are denied (safe default).
|
|
44
|
+
* Passes document scope when available (from `data.scope` or loaded by id).
|
|
45
|
+
*/
|
|
46
|
+
export const isReviewer = (collectionKey) => async (args) => {
|
|
47
|
+
if (!args.req.user)
|
|
48
|
+
return false;
|
|
49
|
+
const { canReview } = getAchievementOptions();
|
|
50
|
+
if (!canReview)
|
|
51
|
+
return false;
|
|
52
|
+
const scopeId = await scopeIdForAccess(args, collectionKey);
|
|
53
|
+
return Boolean(await canReview(args.req.user, scopeId));
|
|
54
|
+
};
|
|
55
|
+
export const isAuthenticated = ({ req }) => Boolean(req.user);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RelationshipFieldClientProps } from 'payload';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
type ActorFieldProps = RelationshipFieldClientProps & {
|
|
4
|
+
eventTypesSlug: string;
|
|
5
|
+
};
|
|
6
|
+
/** Relationship field that only renders when the selected event type requires an actor. */
|
|
7
|
+
export declare function ActorField(props: ActorFieldProps): React.JSX.Element | null;
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=ActorField.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ActorField.d.ts","sourceRoot":"","sources":["../../src/admin/ActorField.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAA;AAE3D,OAAO,KAAoB,MAAM,OAAO,CAAA;AAIxC,KAAK,eAAe,GAAG,4BAA4B,GAAG;IACpD,cAAc,EAAE,MAAM,CAAA;CACvB,CAAA;AAED,2FAA2F;AAC3F,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,4BAchD"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { RelationshipField, useField } from '@payloadcms/ui';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
import { useEventTypeFlags } from './useEventTypeFlags';
|
|
6
|
+
/** Relationship field that only renders when the selected event type requires an actor. */
|
|
7
|
+
export function ActorField(props) {
|
|
8
|
+
const { eventTypesSlug, ...fieldArgs } = props;
|
|
9
|
+
const { requiresActor } = useEventTypeFlags(eventTypesSlug);
|
|
10
|
+
const { setValue, value } = useField({ path: fieldArgs.path });
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!requiresActor && value != null && value !== '') {
|
|
13
|
+
setValue(null);
|
|
14
|
+
}
|
|
15
|
+
}, [requiresActor, setValue, value]);
|
|
16
|
+
if (!requiresActor)
|
|
17
|
+
return null;
|
|
18
|
+
return _jsx(RelationshipField, { ...fieldArgs });
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NumberFieldClientProps } from 'payload';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
type MetricChangeFieldProps = NumberFieldClientProps & {
|
|
4
|
+
eventTypesSlug: string;
|
|
5
|
+
};
|
|
6
|
+
/** Signed change amount — only when the type adjusts a metric and a metric is selected. */
|
|
7
|
+
export declare function MetricChangeField(props: MetricChangeFieldProps): React.JSX.Element | null;
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=MetricChangeField.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MetricChangeField.d.ts","sourceRoot":"","sources":["../../src/admin/MetricChangeField.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,SAAS,CAAA;AAErD,OAAO,KAAoB,MAAM,OAAO,CAAA;AAIxC,KAAK,sBAAsB,GAAG,sBAAsB,GAAG;IACrD,cAAc,EAAE,MAAM,CAAA;CACvB,CAAA;AAED,2FAA2F;AAC3F,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,sBAAsB,4BAgB9D"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { NumberField, useField, useFormFields } from '@payloadcms/ui';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
import { useEventTypeFlags } from './useEventTypeFlags';
|
|
6
|
+
/** Signed change amount — only when the type adjusts a metric and a metric is selected. */
|
|
7
|
+
export function MetricChangeField(props) {
|
|
8
|
+
const { eventTypesSlug, ...fieldArgs } = props;
|
|
9
|
+
const { requiresMetric } = useEventTypeFlags(eventTypesSlug);
|
|
10
|
+
const metricValue = useFormFields(([fields]) => fields.metric?.value);
|
|
11
|
+
const { setValue, value } = useField({ path: fieldArgs.path });
|
|
12
|
+
const show = requiresMetric && Boolean(metricValue);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!show && value != null && value !== '') {
|
|
15
|
+
setValue(null);
|
|
16
|
+
}
|
|
17
|
+
}, [show, setValue, value]);
|
|
18
|
+
if (!show)
|
|
19
|
+
return null;
|
|
20
|
+
return _jsx(NumberField, { ...fieldArgs });
|
|
21
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RelationshipFieldClientProps } from 'payload';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
type MetricFieldProps = RelationshipFieldClientProps & {
|
|
4
|
+
eventTypesSlug: string;
|
|
5
|
+
};
|
|
6
|
+
/** Metric relationship — only for event types that adjust a score. */
|
|
7
|
+
export declare function MetricField(props: MetricFieldProps): React.JSX.Element | null;
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=MetricField.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MetricField.d.ts","sourceRoot":"","sources":["../../src/admin/MetricField.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAA;AAE3D,OAAO,KAAoB,MAAM,OAAO,CAAA;AAIxC,KAAK,gBAAgB,GAAG,4BAA4B,GAAG;IACrD,cAAc,EAAE,MAAM,CAAA;CACvB,CAAA;AAED,sEAAsE;AACtE,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,4BAelD"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { RelationshipField, useField } from '@payloadcms/ui';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
import { useEventTypeFlags } from './useEventTypeFlags';
|
|
6
|
+
/** Metric relationship — only for event types that adjust a score. */
|
|
7
|
+
export function MetricField(props) {
|
|
8
|
+
const { eventTypesSlug, ...fieldArgs } = props;
|
|
9
|
+
const { requiresMetric } = useEventTypeFlags(eventTypesSlug);
|
|
10
|
+
const metric = useField({ path: fieldArgs.path });
|
|
11
|
+
const change = useField({ path: 'change' });
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (requiresMetric)
|
|
14
|
+
return;
|
|
15
|
+
if (metric.value != null && metric.value !== '')
|
|
16
|
+
metric.setValue(null);
|
|
17
|
+
if (change.value != null && change.value !== '')
|
|
18
|
+
change.setValue(null);
|
|
19
|
+
}, [requiresMetric, metric.value, metric.setValue, change.value, change.setValue]);
|
|
20
|
+
if (!requiresMetric)
|
|
21
|
+
return null;
|
|
22
|
+
return _jsx(RelationshipField, { ...fieldArgs });
|
|
23
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type EventTypeFlags = {
|
|
2
|
+
requiresActor: boolean;
|
|
3
|
+
requiresMetric: boolean;
|
|
4
|
+
};
|
|
5
|
+
/** Resolve event-type flags for the selected `type` field (fetch when only an id is present). */
|
|
6
|
+
export declare function useEventTypeFlags(eventTypesSlug: string): EventTypeFlags;
|
|
7
|
+
//# sourceMappingURL=useEventTypeFlags.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useEventTypeFlags.d.ts","sourceRoot":"","sources":["../../src/admin/useEventTypeFlags.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,cAAc,GAAG;IAC3B,aAAa,EAAE,OAAO,CAAA;IACtB,cAAc,EAAE,OAAO,CAAA;CACxB,CAAA;AA2BD,iGAAiG;AACjG,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,cAAc,CAyCxE"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useConfig, useFormFields } from '@payloadcms/ui';
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
function relationId(value) {
|
|
5
|
+
if (typeof value === 'string' && value)
|
|
6
|
+
return value;
|
|
7
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
8
|
+
return String(value);
|
|
9
|
+
if (value && typeof value === 'object' && 'id' in value) {
|
|
10
|
+
const id = value.id;
|
|
11
|
+
if (typeof id === 'string' && id)
|
|
12
|
+
return id;
|
|
13
|
+
if (typeof id === 'number' && Number.isFinite(id))
|
|
14
|
+
return String(id);
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
function flagsFromValue(typeValue) {
|
|
19
|
+
if (!typeValue || typeof typeValue !== 'object')
|
|
20
|
+
return null;
|
|
21
|
+
if (!('requiresActor' in typeValue || 'requiresMetric' in typeValue || 'slug' in typeValue)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const row = typeValue;
|
|
25
|
+
return {
|
|
26
|
+
requiresActor: Boolean(row.requiresActor),
|
|
27
|
+
requiresMetric: Boolean(row.requiresMetric) || row.slug === 'metric.delta',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const empty = { requiresActor: false, requiresMetric: false };
|
|
31
|
+
/** Resolve event-type flags for the selected `type` field (fetch when only an id is present). */
|
|
32
|
+
export function useEventTypeFlags(eventTypesSlug) {
|
|
33
|
+
const { config } = useConfig();
|
|
34
|
+
const typeValue = useFormFields(([fields]) => fields.type?.value);
|
|
35
|
+
const typeId = useMemo(() => relationId(typeValue), [typeValue]);
|
|
36
|
+
const known = flagsFromValue(typeValue);
|
|
37
|
+
const [flags, setFlags] = useState(known ?? empty);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (known) {
|
|
40
|
+
setFlags(known);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!typeId || !eventTypesSlug) {
|
|
44
|
+
setFlags(empty);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const api = config.routes?.api ?? '/api';
|
|
49
|
+
const serverURL = config.serverURL ?? '';
|
|
50
|
+
const url = `${serverURL}${api}/${eventTypesSlug}/${typeId}?depth=0&select[requiresActor]=true&select[requiresMetric]=true&select[slug]=true`;
|
|
51
|
+
void fetch(url, { credentials: 'include', signal: controller.signal })
|
|
52
|
+
.then(async (res) => {
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
setFlags(empty);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const doc = await res.json();
|
|
58
|
+
setFlags(flagsFromValue(doc) ?? empty);
|
|
59
|
+
})
|
|
60
|
+
.catch((err) => {
|
|
61
|
+
if (err && typeof err === 'object' && 'name' in err && err.name === 'AbortError')
|
|
62
|
+
return;
|
|
63
|
+
setFlags(empty);
|
|
64
|
+
});
|
|
65
|
+
return () => controller.abort();
|
|
66
|
+
}, [known, typeId, eventTypesSlug, config.routes?.api, config.serverURL]);
|
|
67
|
+
return flags;
|
|
68
|
+
}
|