@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 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
+ }
@@ -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,2 @@
1
+ export { plugin } from './definition'
2
+ export { messages } from './messages'
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import en from './en.json'
2
+
3
+ export const messages = { en }
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
+ }