@meith/plugin-awards 0.37.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.md +21 -0
- package/README.md +33 -0
- package/package.json +32 -0
- package/src/awards.ts +99 -0
- package/src/definition.ts +116 -0
- package/src/display-cache.ts +48 -0
- package/src/handlers.ts +125 -0
- package/src/index.ts +2 -0
- package/src/messages/en.json +82 -0
- package/src/messages/index.ts +3 -0
- package/src/rules.ts +77 -0
- package/src/schema.ts +38 -0
- package/src/store.ts +259 -0
- package/src/tasks.ts +65 -0
- package/src/ui/admin.tsx +165 -0
- package/src/ui/page.tsx +217 -0
- package/src/ui/rules.tsx +126 -0
- package/src/ui/shared.tsx +134 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordan Harrison and the Meith contributors
|
|
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,33 @@
|
|
|
1
|
+
# @meith/plugin-awards
|
|
2
|
+
|
|
3
|
+
Community awards, manual recognition and achievements.
|
|
4
|
+
|
|
5
|
+
## What it adds
|
|
6
|
+
|
|
7
|
+
- A public catalogue at `/plugins/awards`, award holder pages and member award pages.
|
|
8
|
+
- Admin award creation, ordering, archiving, manual assignment and revocation.
|
|
9
|
+
- Icons beside posts, a profile panel and a seven-day dashboard count.
|
|
10
|
+
- Per-award multiple grants, optional notifications and public reason controls.
|
|
11
|
+
|
|
12
|
+
## Installation and operation
|
|
13
|
+
|
|
14
|
+
The package exports `plugin` and `messages` for the board manifest. It depends
|
|
15
|
+
only on the plugin kit and shared UI. Run migrations when installing it.
|
|
16
|
+
|
|
17
|
+
The [Awards guide](../../docs/guides/community/awards-guide.md) covers setup,
|
|
18
|
+
visibility, icons, notifications, caching and version-one limits.
|
|
19
|
+
|
|
20
|
+
## What it stores
|
|
21
|
+
|
|
22
|
+
One migration creates awards, grants, rules, a dirty-member queue and a scan
|
|
23
|
+
cursor, all under `plugin_awards_*`. Foreign keys remain inside that namespace;
|
|
24
|
+
member ids are plain columns. Account deletion removes grants; account merging
|
|
25
|
+
preserves recognition while collapsing single-only and same-rule duplicates.
|
|
26
|
+
|
|
27
|
+
## Automatic rules
|
|
28
|
+
|
|
29
|
+
Administrators can combine minimum posts, threads, reputation and registration
|
|
30
|
+
age. A five-minute task drains queued changes and scans existing members in
|
|
31
|
+
bounded, resumable batches. Its rule/member unique index prevents duplicate
|
|
32
|
+
awards from repeated runs. The public catalogue describes how awards are earned.
|
|
33
|
+
See the guide for progress controls, revocation behaviour and failure recovery.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meith/plugin-awards",
|
|
3
|
+
"version": "0.37.0",
|
|
4
|
+
"description": "Community awards, manual recognition and achievements.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/meith-dev/meith.git",
|
|
9
|
+
"directory": "plugins/awards"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"!src/**/*.test.*",
|
|
17
|
+
"!src/**/*.type-test.*"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@meith/plugin-kit": "^0.37.0",
|
|
24
|
+
"@meith/ui": "^0.37.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": "^19.2.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/react": "^19.2.18"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/awards.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export const ICON_PATHS = {
|
|
2
|
+
trophy: 'M8 3h8v6a4 4 0 0 1-8 0V3ZM8 5H4v3a4 4 0 0 0 4 4m8-7h4v3a4 4 0 0 1-4 4m-4 1v7m-4 0h8',
|
|
3
|
+
medal: 'm7 3 5 7 5-7M4 3h5m6 0h5M17 15a5 5 0 1 1-10 0 5 5 0 0 1 10 0Z',
|
|
4
|
+
star: 'm12 3 3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1Z',
|
|
5
|
+
shield: 'm12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6Z',
|
|
6
|
+
crown: 'm3 6 5 5 4-8 4 8 5-5-2 13H5Zm2 10h14',
|
|
7
|
+
heart: 'M12 21 3 12C-2 5 7 0 12 7c5-7 14-2 9 5Z',
|
|
8
|
+
flame: 'M12 2c2 7-6 8-3 12 3 0 4-3 4-5 9 7 4 13-1 13S1 15 7 9c0 3 1 3 1 3-2-5 4-7 4-10Z',
|
|
9
|
+
gem: 'm3 8 4-5h10l4 5-9 13Zm0 0h18M7 3l5 18 5-18',
|
|
10
|
+
ribbon: 'M17 8A5 5 0 1 1 7 8a5 5 0 0 1 10 0Zm-9 4-2 9 6-3 6 3-2-9',
|
|
11
|
+
rocket:
|
|
12
|
+
'M9 15 5 11C9 3 16 2 22 2c0 6-1 13-9 17Zm-4-4-3 2v4l7-2m4 4-2 3H7l2-7m6-8h2v2h-2Zm-10 12-3 3',
|
|
13
|
+
book: 'M12 5C8 2 4 3 2 4v16c4-2 7-2 10 0 3-2 6-2 10 0V4c-2-1-6-2-10 1Zm0 0v15',
|
|
14
|
+
handshake: 'm2 8 4-4 5 2 3-2 8 5-4 9-4 3-8-5Zm9-2-4 5 3 2 4-4 6 6m-10 0 5 4m-7-1 4 3',
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
export type IconKind = 'emoji' | 'svg' | 'image'
|
|
18
|
+
export interface AwardDraft {
|
|
19
|
+
readonly name: string
|
|
20
|
+
readonly description: string
|
|
21
|
+
readonly icon_kind: IconKind
|
|
22
|
+
readonly icon: string
|
|
23
|
+
readonly display_order: number
|
|
24
|
+
readonly allow_multiple: boolean
|
|
25
|
+
readonly listed: boolean
|
|
26
|
+
}
|
|
27
|
+
export interface Award extends AwardDraft {
|
|
28
|
+
readonly id: number
|
|
29
|
+
readonly archived_at: Date | string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function asId(value: string | undefined): number | null {
|
|
33
|
+
if (!/^[1-9]\d*$/.test(value ?? '')) return null
|
|
34
|
+
const id = Number(value)
|
|
35
|
+
return Number.isSafeInteger(id) ? id : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseIcon(value: string): { icon_kind: IconKind; icon: string } | null {
|
|
39
|
+
const icon = value.trim()
|
|
40
|
+
if (Object.hasOwn(ICON_PATHS, icon)) return { icon_kind: 'svg', icon }
|
|
41
|
+
if (icon.length > 2048 || /[\s\\\p{Cc}]/u.test(icon)) return null
|
|
42
|
+
if (icon[0] === '/' && icon[1] !== '/') return { icon_kind: 'image', icon }
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(icon)
|
|
45
|
+
if (url.protocol === 'https:' && !url.username && !url.password) {
|
|
46
|
+
return { icon_kind: 'image', icon: url.href }
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
if (
|
|
50
|
+
/^(?:\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3|\p{Extended_Pictographic}\uFE0F?\p{Emoji_Modifier}?(?:\u200D\p{Extended_Pictographic}\uFE0F?\p{Emoji_Modifier}?)*)$/u.test(
|
|
51
|
+
icon,
|
|
52
|
+
)
|
|
53
|
+
) {
|
|
54
|
+
return { icon_kind: 'emoji', icon }
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseAward(
|
|
60
|
+
form: Readonly<Record<string, string>>,
|
|
61
|
+
): { draft: AwardDraft; error?: never } | { draft?: never; error: 'invalid' | 'icon' } {
|
|
62
|
+
const name = (form.name ?? '').trim()
|
|
63
|
+
const description = (form.description ?? '').trim()
|
|
64
|
+
const order = form.display_order?.trim() || '0'
|
|
65
|
+
const display_order = Number(order)
|
|
66
|
+
if (
|
|
67
|
+
!name ||
|
|
68
|
+
name.length > 120 ||
|
|
69
|
+
description.length > 2000 ||
|
|
70
|
+
!/^\d+$/.test(order) ||
|
|
71
|
+
!Number.isSafeInteger(display_order) ||
|
|
72
|
+
display_order > 2147483647
|
|
73
|
+
)
|
|
74
|
+
return { error: 'invalid' }
|
|
75
|
+
const icon = parseIcon(form.icon ?? '🏆')
|
|
76
|
+
if (icon === null) return { error: 'icon' }
|
|
77
|
+
return {
|
|
78
|
+
draft: {
|
|
79
|
+
name,
|
|
80
|
+
description,
|
|
81
|
+
...icon,
|
|
82
|
+
display_order,
|
|
83
|
+
allow_multiple: form.allow_multiple === 'on',
|
|
84
|
+
listed: form.listed === 'on',
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function postbitLimit(
|
|
90
|
+
settings: Readonly<Record<string, string | number | boolean>>,
|
|
91
|
+
): number {
|
|
92
|
+
const value = Number(settings.postbit_limit ?? 5)
|
|
93
|
+
return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.floor(value))) : 5
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface DisplayAward extends Award, Record<string, unknown> {
|
|
97
|
+
readonly count: number
|
|
98
|
+
readonly total: number
|
|
99
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { definePlugin } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import { handleAward, handleGrant, handleRules } from './handlers'
|
|
4
|
+
import en from './messages/en.json'
|
|
5
|
+
import { AWARDS_MIGRATIONS } from './schema'
|
|
6
|
+
import { deleteMember, mergeMember } from './store'
|
|
7
|
+
import { evaluateAwards, queueMember } from './tasks'
|
|
8
|
+
import { AwardsAdmin, GrantAdmin } from './ui/admin'
|
|
9
|
+
import {
|
|
10
|
+
AwardPage,
|
|
11
|
+
AwardsPage,
|
|
12
|
+
Dashboard,
|
|
13
|
+
MemberPage,
|
|
14
|
+
PostbitBadges,
|
|
15
|
+
ProfilePanel,
|
|
16
|
+
} from './ui/page'
|
|
17
|
+
import { RulesAdmin } from './ui/rules'
|
|
18
|
+
|
|
19
|
+
export const plugin = definePlugin({
|
|
20
|
+
key: 'awards',
|
|
21
|
+
name: en['awards.title'],
|
|
22
|
+
nameKey: 'awards.title',
|
|
23
|
+
version: '0.37.0',
|
|
24
|
+
apiVersion: '0',
|
|
25
|
+
description: en['awards.definition.description'],
|
|
26
|
+
descriptionKey: 'awards.definition.description',
|
|
27
|
+
migrations: AWARDS_MIGRATIONS,
|
|
28
|
+
tasks: [{ id: 'evaluate', intervalSeconds: 300, run: evaluateAwards }],
|
|
29
|
+
settings: [
|
|
30
|
+
{
|
|
31
|
+
key: 'postbit_limit',
|
|
32
|
+
type: 'number',
|
|
33
|
+
default: 5,
|
|
34
|
+
label: en['awards.setting.limit'],
|
|
35
|
+
labelKey: 'awards.setting.limit',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: 'notify_on_grant',
|
|
39
|
+
type: 'boolean',
|
|
40
|
+
default: true,
|
|
41
|
+
label: en['awards.setting.notify'],
|
|
42
|
+
labelKey: 'awards.setting.notify',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: 'show_reasons',
|
|
46
|
+
type: 'boolean',
|
|
47
|
+
default: true,
|
|
48
|
+
label: en['awards.setting.reasons'],
|
|
49
|
+
labelKey: 'awards.setting.reasons',
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
notifications: [
|
|
53
|
+
{
|
|
54
|
+
key: 'award_received',
|
|
55
|
+
title: en['awards.notification.subject'],
|
|
56
|
+
titleKey: 'awards.notification.subject',
|
|
57
|
+
description: en['awards.notification.description'],
|
|
58
|
+
descriptionKey: 'awards.notification.description',
|
|
59
|
+
emailByDefault: false,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
navigation: [
|
|
63
|
+
{ key: 'awards', label: 'Awards', labelKey: 'awards.title', path: '', audience: 'all' },
|
|
64
|
+
],
|
|
65
|
+
pages: [
|
|
66
|
+
{
|
|
67
|
+
path: '',
|
|
68
|
+
title: en['awards.title'],
|
|
69
|
+
titleKey: 'awards.title',
|
|
70
|
+
access: 'anonymous',
|
|
71
|
+
render: AwardsPage,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
path: 'award',
|
|
75
|
+
title: en['awards.award'],
|
|
76
|
+
titleKey: 'awards.award',
|
|
77
|
+
access: 'anonymous',
|
|
78
|
+
render: AwardPage,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
path: 'member',
|
|
82
|
+
title: en['awards.member.awards'],
|
|
83
|
+
titleKey: 'awards.member.awards',
|
|
84
|
+
access: 'anonymous',
|
|
85
|
+
render: MemberPage,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
adminPages: [
|
|
89
|
+
{ path: 'awards', title: en['awards.title'], titleKey: 'awards.title', render: AwardsAdmin },
|
|
90
|
+
{ path: 'grant', title: en['awards.grant'], titleKey: 'awards.grant', render: GrantAdmin },
|
|
91
|
+
{ path: 'rules', title: en['awards.rules'], titleKey: 'awards.rules', render: RulesAdmin },
|
|
92
|
+
],
|
|
93
|
+
routes: [
|
|
94
|
+
{ path: 'rules', method: 'POST', access: 'admin', handler: handleRules },
|
|
95
|
+
{ path: 'awards', method: 'POST', access: 'admin', handler: handleAward },
|
|
96
|
+
{ path: 'grant', method: 'POST', access: 'admin', handler: handleGrant },
|
|
97
|
+
],
|
|
98
|
+
hooks: {
|
|
99
|
+
'post.created': async (post, _context, runtime) => queueMember(await runtime(), post.authorId),
|
|
100
|
+
'thread.created': async (thread, _context, runtime) =>
|
|
101
|
+
queueMember(await runtime(), thread.authorId),
|
|
102
|
+
'reputation.changed': async (user, _context, runtime) =>
|
|
103
|
+
queueMember(await runtime(), user.userId),
|
|
104
|
+
'user.registered': async (user, _context, runtime) => queueMember(await runtime(), user.userId),
|
|
105
|
+
'user.activated': async (user, _context, runtime) => queueMember(await runtime(), user.userId),
|
|
106
|
+
'user.deleted': async (user, _context, runtime) =>
|
|
107
|
+
deleteMember((await runtime()).data, user.userId),
|
|
108
|
+
'user.merged': async (user, _context, runtime) =>
|
|
109
|
+
mergeMember((await runtime()).data, user.keptUserId, user.mergedUserId),
|
|
110
|
+
},
|
|
111
|
+
contributions: [
|
|
112
|
+
{ region: 'postbit.badges', render: PostbitBadges },
|
|
113
|
+
{ region: 'profile.panel', render: ProfilePanel },
|
|
114
|
+
{ region: 'admin.dashboard', render: Dashboard },
|
|
115
|
+
],
|
|
116
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PluginData } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import type { DisplayAward } from './awards'
|
|
4
|
+
|
|
5
|
+
type DisplayCache = Map<
|
|
6
|
+
number,
|
|
7
|
+
{ until: number; limit: number; rows: Promise<readonly DisplayAward[]> }
|
|
8
|
+
>
|
|
9
|
+
const CACHE_KEY = Symbol.for('@meith/plugin-awards.display')
|
|
10
|
+
const shared = globalThis as typeof globalThis & { [CACHE_KEY]?: DisplayCache }
|
|
11
|
+
const cache: DisplayCache = shared[CACHE_KEY] ?? new Map()
|
|
12
|
+
shared[CACHE_KEY] = cache
|
|
13
|
+
|
|
14
|
+
export function clearDisplay(userId?: number): void {
|
|
15
|
+
if (userId === undefined) cache.clear()
|
|
16
|
+
else cache.delete(userId)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function cachedAwards(
|
|
20
|
+
data: PluginData,
|
|
21
|
+
userId: number,
|
|
22
|
+
limit: number,
|
|
23
|
+
): Promise<readonly DisplayAward[]> {
|
|
24
|
+
const found = cache.get(userId)
|
|
25
|
+
if (found !== undefined && found.until > Date.now() && found.limit === limit) return found.rows
|
|
26
|
+
cache.delete(userId)
|
|
27
|
+
if (cache.size >= 2000) cache.delete(cache.keys().next().value!)
|
|
28
|
+
const entry = { until: Date.now() + 60_000, limit, rows: displayAwards(data, userId, limit + 1) }
|
|
29
|
+
cache.set(userId, entry)
|
|
30
|
+
void entry.rows.catch(() => {
|
|
31
|
+
if (cache.get(userId) === entry) cache.delete(userId)
|
|
32
|
+
})
|
|
33
|
+
return entry.rows
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function displayAwards(
|
|
37
|
+
data: PluginData,
|
|
38
|
+
userId: number,
|
|
39
|
+
limit: number,
|
|
40
|
+
): Promise<readonly DisplayAward[]> {
|
|
41
|
+
return data.query<DisplayAward>(
|
|
42
|
+
`select a.*, count(*)::int as count, count(*) over ()::int as total
|
|
43
|
+
from plugin_awards_grant g join plugin_awards_award a on a.id = g.award_id
|
|
44
|
+
where g.user_id = $1 and a.archived_at is null
|
|
45
|
+
group by a.id order by a.display_order, a.id limit $2`,
|
|
46
|
+
[userId, limit],
|
|
47
|
+
)
|
|
48
|
+
}
|
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { PluginRequest, PluginResponse, PluginRuntimeContext } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import { asId, parseAward } from './awards'
|
|
4
|
+
import { awardRuleProblem, parseRule } from './rules'
|
|
5
|
+
import {
|
|
6
|
+
archiveAward,
|
|
7
|
+
awardById,
|
|
8
|
+
awardRules,
|
|
9
|
+
deleteAward,
|
|
10
|
+
grantAward,
|
|
11
|
+
revokeGrant,
|
|
12
|
+
saveAward,
|
|
13
|
+
saveRule,
|
|
14
|
+
} from './store'
|
|
15
|
+
import { evaluateAwards } from './tasks'
|
|
16
|
+
|
|
17
|
+
export function toAdmin(page: string, notice: string): PluginResponse {
|
|
18
|
+
return {
|
|
19
|
+
kind: 'redirect',
|
|
20
|
+
to: `/admin/plugins/awards/${page}?notice=${encodeURIComponent(notice)}`,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function handleAward(
|
|
25
|
+
request: PluginRequest,
|
|
26
|
+
context: PluginRuntimeContext,
|
|
27
|
+
): Promise<PluginResponse> {
|
|
28
|
+
const form = request.form ?? {}
|
|
29
|
+
const id = asId(form.id)
|
|
30
|
+
if (form.id && id === null) return toAdmin('awards', 'invalid')
|
|
31
|
+
if (form.action === 'delete' || form.action === 'archive' || form.action === 'restore') {
|
|
32
|
+
if (id === null || (await awardById(context.data, id)) === null)
|
|
33
|
+
return toAdmin('awards', 'missing')
|
|
34
|
+
if (form.action === 'delete')
|
|
35
|
+
return toAdmin('awards', (await deleteAward(context.data, id)) ? 'deleted' : 'held')
|
|
36
|
+
await archiveAward(context.data, id, form.action === 'archive')
|
|
37
|
+
return toAdmin('awards', 'saved')
|
|
38
|
+
}
|
|
39
|
+
const parsed = parseAward(form)
|
|
40
|
+
if (parsed.error !== undefined) return toAdmin('awards', parsed.error)
|
|
41
|
+
return toAdmin('awards', await saveAward(context.data, parsed.draft, id))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function handleGrant(
|
|
45
|
+
request: PluginRequest,
|
|
46
|
+
context: PluginRuntimeContext,
|
|
47
|
+
): Promise<PluginResponse> {
|
|
48
|
+
const form = request.form ?? {}
|
|
49
|
+
if (form.action === 'revoke') {
|
|
50
|
+
const id = asId(form.id)
|
|
51
|
+
if (id === null) return toAdmin('grant', 'invalid')
|
|
52
|
+
await revokeGrant(context.data, id)
|
|
53
|
+
return toAdmin('grant', 'revoked')
|
|
54
|
+
}
|
|
55
|
+
const awardId = asId(form.award_id)
|
|
56
|
+
const reason = (form.reason ?? '').trim()
|
|
57
|
+
const names = [
|
|
58
|
+
...new Set(
|
|
59
|
+
(form.usernames ?? '')
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((name) => name.trim().toLowerCase())
|
|
62
|
+
.filter(Boolean),
|
|
63
|
+
),
|
|
64
|
+
]
|
|
65
|
+
if (awardId === null || !names.length || names.length > 200 || reason.length > 2000)
|
|
66
|
+
return toAdmin('grant', 'invalid')
|
|
67
|
+
const award = await awardById(context.data, awardId)
|
|
68
|
+
if (award === null || award.archived_at !== null) return toAdmin('grant', 'missing')
|
|
69
|
+
const members = await Promise.all(names.map((name) => context.users.byUsername(name)))
|
|
70
|
+
if (members.some((member) => member === null)) return toAdmin('grant', 'unknown')
|
|
71
|
+
let granted = 0
|
|
72
|
+
for (const member of members) {
|
|
73
|
+
if (
|
|
74
|
+
member !== null &&
|
|
75
|
+
(await grantAward(context, {
|
|
76
|
+
awardId,
|
|
77
|
+
userId: member.userId,
|
|
78
|
+
byUserId: request.viewer.userId,
|
|
79
|
+
reason,
|
|
80
|
+
})) !== null
|
|
81
|
+
)
|
|
82
|
+
granted++
|
|
83
|
+
}
|
|
84
|
+
return toAdmin(
|
|
85
|
+
'grant',
|
|
86
|
+
granted === members.length ? 'granted' : granted === 0 ? 'already' : 'partial',
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function handleRules(
|
|
91
|
+
request: PluginRequest,
|
|
92
|
+
context: PluginRuntimeContext,
|
|
93
|
+
): Promise<PluginResponse> {
|
|
94
|
+
const form = request.form ?? {}
|
|
95
|
+
if (form.action === 'run') {
|
|
96
|
+
await evaluateAwards(context)
|
|
97
|
+
return toAdmin('rules', 'evaluated')
|
|
98
|
+
}
|
|
99
|
+
if (form.action === 'reset') {
|
|
100
|
+
await context.data.query(`update plugin_awards_scan set cursor = 0 where id = 1`)
|
|
101
|
+
return toAdmin('rules', 'reset')
|
|
102
|
+
}
|
|
103
|
+
const id = asId(form.id)
|
|
104
|
+
if (form.id && id === null) return toAdmin('rules', 'rule-invalid')
|
|
105
|
+
if (id !== null && !(await awardRules(context.data)).some((rule) => rule.id === id))
|
|
106
|
+
return toAdmin('rules', 'rule-missing')
|
|
107
|
+
if (form.action === 'delete' || form.action === 'enable' || form.action === 'disable') {
|
|
108
|
+
if (id === null) return toAdmin('rules', 'rule-missing')
|
|
109
|
+
if (form.action === 'delete')
|
|
110
|
+
await context.data.query(`delete from plugin_awards_rule where id = $1`, [id])
|
|
111
|
+
else
|
|
112
|
+
await context.data.query(
|
|
113
|
+
`update plugin_awards_rule set enabled = $2, updated_at = now() where id = $1`,
|
|
114
|
+
[id, form.action === 'enable'],
|
|
115
|
+
)
|
|
116
|
+
return toAdmin('rules', 'rule-saved')
|
|
117
|
+
}
|
|
118
|
+
const rule = parseRule(form)
|
|
119
|
+
const problem = awardRuleProblem(rule)
|
|
120
|
+
if (problem !== null) return toAdmin('rules', problem)
|
|
121
|
+
const award = await awardById(context.data, rule.awardId)
|
|
122
|
+
if (award === null || award.archived_at !== null) return toAdmin('rules', 'missing')
|
|
123
|
+
await saveRule(context.data, rule, id)
|
|
124
|
+
return toAdmin('rules', 'rule-saved')
|
|
125
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"awards.actions": "Actions",
|
|
3
|
+
"awards.archive": "Archive",
|
|
4
|
+
"awards.award": "Award",
|
|
5
|
+
"awards.create": "Create award",
|
|
6
|
+
"awards.dashboard": "Grants in the last 7 days",
|
|
7
|
+
"awards.date": "Granted (UTC)",
|
|
8
|
+
"awards.definition.description": "Community awards, manual recognition and achievements.",
|
|
9
|
+
"awards.delete": "Delete",
|
|
10
|
+
"awards.deletedMember": "Deleted member",
|
|
11
|
+
"awards.description": "Description",
|
|
12
|
+
"awards.edit": "Edit award",
|
|
13
|
+
"awards.empty": "No awards are listed yet.",
|
|
14
|
+
"awards.filter": "Filter by username",
|
|
15
|
+
"awards.filter.apply": "Filter",
|
|
16
|
+
"awards.grant": "Grant awards",
|
|
17
|
+
"awards.holders": "Holders",
|
|
18
|
+
"awards.icon": "Icon",
|
|
19
|
+
"awards.icon.help": "Use one emoji, a same-origin image path, an HTTPS image URL, or a built-in icon:",
|
|
20
|
+
"awards.listed": "Listed in the catalogue",
|
|
21
|
+
"awards.member": "Member",
|
|
22
|
+
"awards.member.awards": "Member awards",
|
|
23
|
+
"awards.member.empty": "No awards yet.",
|
|
24
|
+
"awards.more": "See all awards",
|
|
25
|
+
"awards.multiple": "Allow multiple grants per member",
|
|
26
|
+
"awards.name": "Name",
|
|
27
|
+
"awards.next": "Next",
|
|
28
|
+
"awards.notice.already": "These members already hold this award.",
|
|
29
|
+
"awards.notice.deleted": "Award deleted.",
|
|
30
|
+
"awards.notice.evaluated": "Evaluation batch finished.",
|
|
31
|
+
"awards.notice.granted": "Awards granted.",
|
|
32
|
+
"awards.notice.held": "This award has grants and cannot be deleted. Use Archive to retire it.",
|
|
33
|
+
"awards.notice.icon": "Use one emoji, a built-in icon name, or a same-origin or HTTPS image URL.",
|
|
34
|
+
"awards.notice.invalid": "Check the required fields and whole numbers. Names allow 120 characters and descriptions and reasons allow 2,000.",
|
|
35
|
+
"awards.notice.missing": "The award does not exist or is archived.",
|
|
36
|
+
"awards.notice.multiple": "Members hold multiple grants. Revoke duplicates before making this a single-only award.",
|
|
37
|
+
"awards.notice.partial": "Some awards were granted; other members already hold this award.",
|
|
38
|
+
"awards.notice.reset": "The next evaluation starts the full scan from the beginning.",
|
|
39
|
+
"awards.notice.revoked": "Grant revoked.",
|
|
40
|
+
"awards.notice.rule-empty": "Set at least one threshold. Zero is allowed and explicitly matches every member for that criterion.",
|
|
41
|
+
"awards.notice.rule-invalid": "Choose an award, enter a title of up to 120 characters, and use nonnegative whole-number thresholds.",
|
|
42
|
+
"awards.notice.rule-missing": "That rule no longer exists.",
|
|
43
|
+
"awards.notice.rule-saved": "Rule saved.",
|
|
44
|
+
"awards.notice.saved": "Award saved.",
|
|
45
|
+
"awards.notice.unknown": "A username was not found. Check all names; no awards were granted.",
|
|
46
|
+
"awards.notification.description": "Staff or an achievement rule gave you an award.",
|
|
47
|
+
"awards.notification.subject": "You received an award",
|
|
48
|
+
"awards.order": "Display order",
|
|
49
|
+
"awards.pagination": "Award holders pages",
|
|
50
|
+
"awards.previous": "Previous",
|
|
51
|
+
"awards.reason": "Reason",
|
|
52
|
+
"awards.recent": "Recent grants",
|
|
53
|
+
"awards.restore": "Unarchive",
|
|
54
|
+
"awards.revoke": "Revoke",
|
|
55
|
+
"awards.rule.all": "All of:",
|
|
56
|
+
"awards.rule.budget": "Every five minutes, evaluate up to 500 queued members and scan up to 1,000 members. Run now uses the same budget.",
|
|
57
|
+
"awards.rule.completed": "Last full scan completed (UTC)",
|
|
58
|
+
"awards.rule.create": "Create rule",
|
|
59
|
+
"awards.rule.cursor": "Full scan cursor (member id)",
|
|
60
|
+
"awards.rule.days": "Minimum days registered",
|
|
61
|
+
"awards.rule.disable": "Disable",
|
|
62
|
+
"awards.rule.edit": "Edit rule",
|
|
63
|
+
"awards.rule.enable": "Enable",
|
|
64
|
+
"awards.rule.enabled": "Enabled",
|
|
65
|
+
"awards.rule.help": "Leave a threshold blank to ignore it. Set at least one; zero is allowed. All configured thresholds must hold.",
|
|
66
|
+
"awards.rule.never": "Not yet",
|
|
67
|
+
"awards.rule.posts": "Minimum posts",
|
|
68
|
+
"awards.rule.reputation": "Minimum reputation",
|
|
69
|
+
"awards.rule.reset": "Re-check everyone",
|
|
70
|
+
"awards.rule.run": "Run now",
|
|
71
|
+
"awards.rule.save": "Save rule",
|
|
72
|
+
"awards.rule.threads": "Minimum threads",
|
|
73
|
+
"awards.rule.title": "Rule title",
|
|
74
|
+
"awards.rules": "Rules",
|
|
75
|
+
"awards.save": "Save award",
|
|
76
|
+
"awards.setting.limit": "Maximum awards beside each post (0 hides, up to 100)",
|
|
77
|
+
"awards.setting.notify": "Notify members when they receive an award",
|
|
78
|
+
"awards.setting.reasons": "Show manual grant reasons publicly",
|
|
79
|
+
"awards.staff": "Given by staff",
|
|
80
|
+
"awards.title": "Awards",
|
|
81
|
+
"awards.usernames": "Usernames (comma-separated)"
|
|
82
|
+
}
|
package/src/rules.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { PluginUserStanding } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
export interface AwardRuleInput {
|
|
4
|
+
readonly awardId: number
|
|
5
|
+
readonly title: string
|
|
6
|
+
readonly enabled: boolean
|
|
7
|
+
readonly minPostCount: number | null
|
|
8
|
+
readonly minThreadCount: number | null
|
|
9
|
+
readonly minReputation: number | null
|
|
10
|
+
readonly minDaysRegistered: number | null
|
|
11
|
+
}
|
|
12
|
+
export interface AwardRule extends AwardRuleInput {
|
|
13
|
+
readonly id: number
|
|
14
|
+
}
|
|
15
|
+
export const CRITERIA = [
|
|
16
|
+
'minPostCount',
|
|
17
|
+
'minThreadCount',
|
|
18
|
+
'minReputation',
|
|
19
|
+
'minDaysRegistered',
|
|
20
|
+
] as const
|
|
21
|
+
|
|
22
|
+
export function awardRuleProblem(input: AwardRuleInput): 'rule-invalid' | 'rule-empty' | null {
|
|
23
|
+
if (
|
|
24
|
+
!input.title.trim() ||
|
|
25
|
+
input.title.trim().length > 120 ||
|
|
26
|
+
!Number.isSafeInteger(input.awardId) ||
|
|
27
|
+
input.awardId <= 0
|
|
28
|
+
)
|
|
29
|
+
return 'rule-invalid'
|
|
30
|
+
for (const key of CRITERIA) {
|
|
31
|
+
const value = input[key]
|
|
32
|
+
if (value !== null && (!Number.isSafeInteger(value) || value < 0 || value > 2147483647))
|
|
33
|
+
return 'rule-invalid'
|
|
34
|
+
}
|
|
35
|
+
return CRITERIA.every((key) => input[key] === null) ? 'rule-empty' : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseRule(form: Readonly<Record<string, string>>): AwardRuleInput {
|
|
39
|
+
const threshold = (key: string): number | null => {
|
|
40
|
+
const value = form[key]?.trim() ?? ''
|
|
41
|
+
return value === '' ? null : /^\d+$/.test(value) ? Number(value) : Number.NaN
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
awardId: Number(form.award_id),
|
|
45
|
+
title: (form.title ?? '').trim(),
|
|
46
|
+
enabled: form.enabled === 'on',
|
|
47
|
+
minPostCount: threshold('min_post_count'),
|
|
48
|
+
minThreadCount: threshold('min_thread_count'),
|
|
49
|
+
minReputation: threshold('min_reputation'),
|
|
50
|
+
minDaysRegistered: threshold('min_days_registered'),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function evaluateAwardRules(
|
|
55
|
+
rules: readonly AwardRule[],
|
|
56
|
+
members: readonly PluginUserStanding[],
|
|
57
|
+
now: Date = new Date(),
|
|
58
|
+
): Array<{ userId: number; ruleId: number; awardId: number }> {
|
|
59
|
+
const active = rules
|
|
60
|
+
.filter((rule) => rule.enabled && awardRuleProblem(rule) === null)
|
|
61
|
+
.sort((a, b) => a.id - b.id)
|
|
62
|
+
const outcomes: Array<{ userId: number; ruleId: number; awardId: number }> = []
|
|
63
|
+
for (const member of members) {
|
|
64
|
+
for (const rule of active) {
|
|
65
|
+
if (rule.minPostCount !== null && !(member.postCount >= rule.minPostCount)) continue
|
|
66
|
+
if (rule.minThreadCount !== null && !(member.threadCount >= rule.minThreadCount)) continue
|
|
67
|
+
if (rule.minReputation !== null && !(member.reputation >= rule.minReputation)) continue
|
|
68
|
+
if (
|
|
69
|
+
rule.minDaysRegistered !== null &&
|
|
70
|
+
!((now.getTime() - member.registeredAt.getTime()) / 86_400_000 >= rule.minDaysRegistered)
|
|
71
|
+
)
|
|
72
|
+
continue
|
|
73
|
+
outcomes.push({ userId: member.userId, ruleId: rule.id, awardId: rule.awardId })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return outcomes
|
|
77
|
+
}
|