@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/src/schema.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { PluginMigration } from '@meith/plugin-kit'
2
+
3
+ export const AWARDS_MIGRATIONS: readonly PluginMigration[] = [
4
+ {
5
+ id: '0001_awards',
6
+ statements: [
7
+ `create table plugin_awards_award (
8
+ id bigint generated by default as identity primary key,
9
+ name text not null, description text not null default '',
10
+ icon_kind text not null default 'emoji', icon text not null default '🏆',
11
+ display_order int not null default 0, allow_multiple bool not null default false,
12
+ listed bool not null default true, archived_at timestamptz,
13
+ created_at timestamptz not null default now(), updated_at timestamptz not null default now()
14
+ )`,
15
+ `create table plugin_awards_rule (
16
+ id bigint generated by default as identity primary key,
17
+ award_id bigint not null references plugin_awards_award(id) on delete cascade,
18
+ title text not null, enabled bool not null default true,
19
+ min_post_count int, min_thread_count int, min_reputation int, min_days_registered int,
20
+ created_at timestamptz not null default now(), updated_at timestamptz not null default now()
21
+ )`,
22
+ `create table plugin_awards_grant (
23
+ id bigint generated by default as identity primary key,
24
+ award_id bigint not null references plugin_awards_award(id) on delete cascade,
25
+ user_id int not null, rule_id bigint, granted_by_user_id int,
26
+ reason text not null default '', granted_at timestamptz not null default now()
27
+ )`,
28
+ `create index plugin_awards_grant_user on plugin_awards_grant (user_id, granted_at desc)`,
29
+ `create index plugin_awards_grant_award on plugin_awards_grant (award_id, granted_at desc)`,
30
+ `create unique index plugin_awards_grant_rule_user on plugin_awards_grant (rule_id, user_id) where rule_id is not null`,
31
+ `create table plugin_awards_dirty (user_id int primary key, queued_at timestamptz not null default now())`,
32
+ `create table plugin_awards_scan (
33
+ id smallint primary key default 1 check (id = 1), cursor int not null default 0, completed_at timestamptz
34
+ )`,
35
+ `insert into plugin_awards_scan (id) values (1)`,
36
+ ],
37
+ },
38
+ ]
package/src/store.ts ADDED
@@ -0,0 +1,259 @@
1
+ import type { PluginData, PluginRuntimeContext } from '@meith/plugin-kit'
2
+
3
+ import type { Award, AwardDraft, DisplayAward } from './awards'
4
+ import { clearDisplay } from './display-cache'
5
+ import type { AwardRule, AwardRuleInput } from './rules'
6
+
7
+ type AwardRow = Award & Record<string, unknown>
8
+ export interface GrantRow extends AwardRow {
9
+ readonly grant_id: number
10
+ readonly user_id: number
11
+ readonly rule_id: number | null
12
+ readonly reason: string
13
+ readonly granted_at: string | Date
14
+ }
15
+
16
+ export async function allAwards(
17
+ data: PluginData,
18
+ publicOnly = false,
19
+ ): Promise<readonly DisplayAward[]> {
20
+ return data.query<DisplayAward>(
21
+ `select a.*, count(distinct g.user_id)::int as count
22
+ from plugin_awards_award a left join plugin_awards_grant g on g.award_id = a.id
23
+ where ($1 = false or (a.listed and a.archived_at is null))
24
+ group by a.id order by a.display_order, a.id`,
25
+ [publicOnly],
26
+ )
27
+ }
28
+
29
+ export async function awardById(data: PluginData, id: number): Promise<AwardRow | null> {
30
+ return data.one<AwardRow>(`select * from plugin_awards_award where id = $1`, [id])
31
+ }
32
+
33
+ export async function saveAward(
34
+ data: PluginData,
35
+ draft: AwardDraft,
36
+ id: number | null,
37
+ ): Promise<'saved' | 'missing' | 'multiple'> {
38
+ const params = [
39
+ draft.name,
40
+ draft.description,
41
+ draft.icon_kind,
42
+ draft.icon,
43
+ draft.display_order,
44
+ draft.allow_multiple,
45
+ draft.listed,
46
+ ]
47
+ const result = await data.tx(async (tx) => {
48
+ if (id === null) {
49
+ await tx.query(
50
+ `insert into plugin_awards_award
51
+ (name, description, icon_kind, icon, display_order, allow_multiple, listed)
52
+ values ($1, $2, $3, $4, $5, $6, $7)`,
53
+ params,
54
+ )
55
+ } else {
56
+ if (
57
+ (await tx.one(`select id from plugin_awards_award where id = $1 for update`, [id])) === null
58
+ )
59
+ return 'missing'
60
+ if (
61
+ !draft.allow_multiple &&
62
+ (await tx.one(
63
+ `select user_id from plugin_awards_grant
64
+ where award_id = $1 group by user_id having count(*) > 1 limit 1`,
65
+ [id],
66
+ )) !== null
67
+ )
68
+ return 'multiple'
69
+ await tx.query(
70
+ `update plugin_awards_award set name = $1, description = $2,
71
+ icon_kind = $3, icon = $4, display_order = $5, allow_multiple = $6, listed = $7,
72
+ updated_at = now() where id = $8`,
73
+ [...params, id],
74
+ )
75
+ }
76
+ return 'saved'
77
+ })
78
+ clearDisplay()
79
+ return result
80
+ }
81
+
82
+ export async function deleteAward(data: PluginData, id: number): Promise<boolean> {
83
+ return data.tx(async (tx) => {
84
+ await tx.one(`select id from plugin_awards_award where id = $1 for update`, [id])
85
+ const row = await tx.one(
86
+ `delete from plugin_awards_award where id = $1
87
+ and not exists (select 1 from plugin_awards_grant where award_id = $1) returning id`,
88
+ [id],
89
+ )
90
+ clearDisplay()
91
+ return row !== null
92
+ })
93
+ }
94
+
95
+ export async function archiveAward(data: PluginData, id: number, archived: boolean): Promise<void> {
96
+ await data.query(
97
+ `update plugin_awards_award set archived_at = case when $2 then now() else null end, updated_at = now() where id = $1`,
98
+ [id, archived],
99
+ )
100
+ clearDisplay()
101
+ }
102
+
103
+ export async function grantAward(
104
+ context: PluginRuntimeContext,
105
+ input: {
106
+ awardId: number
107
+ userId: number
108
+ byUserId: number | null
109
+ reason: string
110
+ ruleId?: number
111
+ },
112
+ ): Promise<number | null> {
113
+ const row = await context.data.tx(async (tx) => {
114
+ const award = await tx.one<AwardRow>(
115
+ `select * from plugin_awards_award where id = $1 for update`,
116
+ [input.awardId],
117
+ )
118
+ if (award === null || award.archived_at !== null) return null
119
+ return tx.one<{ id: number }>(
120
+ `insert into plugin_awards_grant
121
+ (award_id, user_id, granted_by_user_id, reason, rule_id)
122
+ select $1, $2, $3, $4, $5 where $6 or not exists (
123
+ select 1 from plugin_awards_grant where award_id = $1 and user_id = $2
124
+ ) on conflict do nothing returning id`,
125
+ [
126
+ input.awardId,
127
+ input.userId,
128
+ input.byUserId,
129
+ input.reason,
130
+ input.ruleId ?? null,
131
+ award.allow_multiple,
132
+ ],
133
+ )
134
+ })
135
+ if (row === null) return null
136
+ clearDisplay(input.userId)
137
+ if (context.settings.notify_on_grant !== false) {
138
+ await context.notify.send({
139
+ userId: input.userId,
140
+ kind: 'award_received',
141
+ subjectKey: 'awards.notification.subject',
142
+ href: `/plugins/awards/member?id=${input.userId}`,
143
+ dedupeKey: `grant:${row.id}`,
144
+ })
145
+ }
146
+ return Number(row.id)
147
+ }
148
+
149
+ export async function revokeGrant(data: PluginData, id: number): Promise<void> {
150
+ const row = await data.one<{ user_id: number }>(
151
+ `delete from plugin_awards_grant where id = $1 returning user_id`,
152
+ [id],
153
+ )
154
+ if (row !== null) clearDisplay(Number(row.user_id))
155
+ }
156
+
157
+ export async function memberGrants(data: PluginData, userId: number): Promise<readonly GrantRow[]> {
158
+ return data.query<GrantRow>(
159
+ `select a.*, g.id as grant_id, g.user_id, g.rule_id, g.reason, g.granted_at
160
+ from plugin_awards_grant g join plugin_awards_award a on a.id = g.award_id
161
+ where g.user_id = $1 and a.archived_at is null order by a.display_order, a.id, g.granted_at desc`,
162
+ [userId],
163
+ )
164
+ }
165
+
166
+ export async function recentGrants(
167
+ data: PluginData,
168
+ userId: number | null,
169
+ ): Promise<readonly GrantRow[]> {
170
+ return data.query<GrantRow>(
171
+ `select a.*, g.id as grant_id, g.user_id, g.rule_id, g.reason, g.granted_at
172
+ from plugin_awards_grant g join plugin_awards_award a on a.id = g.award_id
173
+ where ($1::int is null or g.user_id = $1) order by g.granted_at desc, g.id desc limit 50`,
174
+ [userId],
175
+ )
176
+ }
177
+
178
+ export async function deleteMember(data: PluginData, userId: number): Promise<void> {
179
+ await data.tx(async (tx) => {
180
+ await tx.query(`delete from plugin_awards_grant where user_id = $1`, [userId])
181
+ await tx.query(`delete from plugin_awards_dirty where user_id = $1`, [userId])
182
+ })
183
+ clearDisplay(userId)
184
+ }
185
+
186
+ export async function mergeMember(data: PluginData, kept: number, merged: number): Promise<void> {
187
+ if (kept === merged) return
188
+ await data.tx(async (tx) => {
189
+ await tx.query(`select id from plugin_awards_award order by id for update`)
190
+ await tx.query(
191
+ `delete from plugin_awards_grant g using plugin_awards_grant other
192
+ where g.user_id = $2 and other.user_id = $1 and g.rule_id = other.rule_id`,
193
+ [kept, merged],
194
+ )
195
+ await tx.query(`update plugin_awards_grant set user_id = $1 where user_id = $2`, [kept, merged])
196
+ await tx.query(
197
+ `delete from plugin_awards_grant g using plugin_awards_award a
198
+ where g.award_id = a.id and not a.allow_multiple and g.user_id = $1
199
+ and exists (select 1 from plugin_awards_grant other
200
+ where other.award_id = g.award_id and other.user_id = $1 and other.id < g.id)`,
201
+ [kept],
202
+ )
203
+ await tx.query(`delete from plugin_awards_dirty where user_id = $1`, [merged])
204
+ await tx.query(`insert into plugin_awards_dirty (user_id) values ($1) on conflict do nothing`, [
205
+ kept,
206
+ ])
207
+ })
208
+ clearDisplay(kept)
209
+ clearDisplay(merged)
210
+ }
211
+
212
+ export async function awardRules(
213
+ data: PluginData,
214
+ enabledOnly = false,
215
+ ): Promise<readonly AwardRule[]> {
216
+ return data
217
+ .query<AwardRule & Record<string, unknown>>(
218
+ `select r.id, r.award_id as "awardId", r.title, r.enabled,
219
+ r.min_post_count as "minPostCount", r.min_thread_count as "minThreadCount",
220
+ r.min_reputation as "minReputation", r.min_days_registered as "minDaysRegistered"
221
+ from plugin_awards_rule r join plugin_awards_award a on a.id = r.award_id
222
+ where ($1 = false or (r.enabled and a.archived_at is null)) order by r.id`,
223
+ [enabledOnly],
224
+ )
225
+ .then((rows) =>
226
+ rows.map((row) => ({ ...row, id: Number(row.id), awardId: Number(row.awardId) })),
227
+ )
228
+ }
229
+
230
+ export async function saveRule(
231
+ data: PluginData,
232
+ rule: AwardRuleInput,
233
+ id: number | null,
234
+ ): Promise<void> {
235
+ const params = [
236
+ rule.awardId,
237
+ rule.title,
238
+ rule.enabled,
239
+ rule.minPostCount,
240
+ rule.minThreadCount,
241
+ rule.minReputation,
242
+ rule.minDaysRegistered,
243
+ ]
244
+ if (id === null) {
245
+ await data.query(
246
+ `insert into plugin_awards_rule
247
+ (award_id, title, enabled, min_post_count, min_thread_count, min_reputation, min_days_registered)
248
+ values ($1, $2, $3, $4, $5, $6, $7)`,
249
+ params,
250
+ )
251
+ } else {
252
+ await data.query(
253
+ `update plugin_awards_rule set award_id = $1, title = $2, enabled = $3,
254
+ min_post_count = $4, min_thread_count = $5, min_reputation = $6, min_days_registered = $7,
255
+ updated_at = now() where id = $8`,
256
+ [...params, id],
257
+ )
258
+ }
259
+ }
package/src/tasks.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { PluginRuntimeContext, PluginUserStanding } from '@meith/plugin-kit'
2
+
3
+ import { evaluateAwardRules } from './rules'
4
+ import { awardRules, grantAward } from './store'
5
+
6
+ export async function queueMember(context: PluginRuntimeContext, userId: number): Promise<void> {
7
+ await context.data.query(
8
+ `insert into plugin_awards_dirty (user_id) values ($1) on conflict do nothing`,
9
+ [userId],
10
+ )
11
+ }
12
+
13
+ export async function evaluateAwards(context: PluginRuntimeContext): Promise<void> {
14
+ const rules = await awardRules(context.data, true)
15
+ const now = new Date()
16
+ const evaluate = async (members: readonly PluginUserStanding[]) => {
17
+ for (const outcome of evaluateAwardRules(rules, members, now)) {
18
+ await grantAward(context, { ...outcome, byUserId: null, reason: '' })
19
+ }
20
+ }
21
+
22
+ for (let drained = 0; drained < 500; ) {
23
+ const limit = Math.min(200, 500 - drained)
24
+ const rows = await context.data.query<{ user_id: number }>(
25
+ `delete from plugin_awards_dirty
26
+ where user_id in (select user_id from plugin_awards_dirty order by queued_at, user_id limit $1 for update skip locked)
27
+ returning user_id`,
28
+ [limit],
29
+ )
30
+ const ids = rows.map((row) => Number(row.user_id))
31
+ if (ids.length === 0) break
32
+ try {
33
+ await evaluate(await context.users.standing(ids))
34
+ } catch (error) {
35
+ await context.data.query(
36
+ `insert into plugin_awards_dirty (user_id)
37
+ select unnest($1::int[]) on conflict do nothing`,
38
+ [ids],
39
+ )
40
+ throw error
41
+ }
42
+ drained += ids.length
43
+ if (ids.length < limit) break
44
+ }
45
+
46
+ const state = await context.data.one<{ cursor: number }>(
47
+ `select cursor from plugin_awards_scan where id = 1`,
48
+ )
49
+ let cursor = Number(state?.cursor ?? 0)
50
+ for (let scanned = 0; scanned < 1000; ) {
51
+ const members = await context.users.scan({ afterUserId: cursor, limit: 200 })
52
+ await evaluate(members)
53
+ const complete = members.length < 200
54
+ const next = complete ? 0 : members[members.length - 1]!.userId
55
+ const advanced = await context.data.one(
56
+ `update plugin_awards_scan set cursor = $1,
57
+ completed_at = case when $2 then now() else completed_at end
58
+ where id = 1 and cursor = $3 returning cursor`,
59
+ [next, complete, cursor],
60
+ )
61
+ if (advanced === null || complete) break
62
+ cursor = next
63
+ scanned += members.length
64
+ }
65
+ }
@@ -0,0 +1,165 @@
1
+ import type { PluginAdminPageContext } from '@meith/plugin-kit'
2
+ import { controlVariants, surfaceVariants, textLinkVariants } from '@meith/ui'
3
+
4
+ import { asId, ICON_PATHS } from '../awards'
5
+ import { allAwards, awardById, recentGrants } from '../store'
6
+ import { action, button, checkbox, date, field, icon, notice, translated } from './shared'
7
+
8
+ export async function AwardsAdmin(context: PluginAdminPageContext) {
9
+ const awards = await allAwards(context.data)
10
+ const editId = asId(context.query.edit)
11
+ const edit = editId === null ? null : await awardById(context.data, editId)
12
+ return (
13
+ <div className="flex flex-col gap-4">
14
+ {notice(context, context.query.notice)}
15
+ <section className={surfaceVariants({ padded: true })}>
16
+ <h2 className="mb-4 font-semibold">
17
+ {translated(context, edit === null ? 'awards.create' : 'awards.edit')}
18
+ </h2>
19
+ <form
20
+ method="post"
21
+ action="/admin/api/plugins/awards/awards"
22
+ className="grid gap-3 sm:grid-cols-2"
23
+ >
24
+ {edit !== null && <input type="hidden" name="id" value={edit.id} />}
25
+ {field(context, 'name', 'awards.name', edit?.name ?? '', 'text', true)}
26
+ {field(context, 'icon', 'awards.icon', edit?.icon ?? '🏆', 'text', true)}
27
+ <p className="text-sm text-muted-foreground sm:col-span-2">
28
+ {translated(context, 'awards.icon.help')} {Object.keys(ICON_PATHS).join(', ')}
29
+ </p>
30
+ <label className="flex flex-col gap-1 sm:col-span-2">
31
+ {translated(context, 'awards.description')}
32
+ <textarea
33
+ name="description"
34
+ maxLength={2000}
35
+ defaultValue={edit?.description ?? ''}
36
+ className={controlVariants()}
37
+ />
38
+ </label>
39
+ {field(context, 'display_order', 'awards.order', edit?.display_order ?? 0, 'number')}
40
+ {checkbox(context, 'allow_multiple', 'awards.multiple', edit?.allow_multiple ?? false)}
41
+ {checkbox(context, 'listed', 'awards.listed', edit?.listed ?? true)}
42
+ <div>{button(context, 'awards.save')}</div>
43
+ </form>
44
+ </section>
45
+ <ul className="flex flex-col gap-3">
46
+ {awards.map((award) => (
47
+ <li
48
+ key={award.id}
49
+ className={surfaceVariants({
50
+ padded: true,
51
+ className: 'flex flex-wrap items-center gap-3',
52
+ })}
53
+ >
54
+ {icon(award)}
55
+ <a
56
+ href={`/admin/plugins/awards/awards?edit=${award.id}`}
57
+ className={textLinkVariants()}
58
+ >
59
+ {award.name}
60
+ </a>
61
+ <span>
62
+ {translated(context, 'awards.order')}: {award.display_order}
63
+ </span>
64
+ <span>
65
+ {translated(context, 'awards.holders')}: {award.count}
66
+ </span>
67
+ {action(
68
+ context,
69
+ 'awards',
70
+ award.id,
71
+ award.archived_at === null ? 'archive' : 'restore',
72
+ award.archived_at === null ? 'awards.archive' : 'awards.restore',
73
+ )}
74
+ {action(context, 'awards', award.id, 'delete', 'awards.delete')}
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ </div>
79
+ )
80
+ }
81
+
82
+ export async function GrantAdmin(context: PluginAdminPageContext) {
83
+ const awards = (await allAwards(context.data)).filter((award) => award.archived_at === null)
84
+ const filter = context.query.user?.trim() ?? ''
85
+ const member = filter === '' ? null : await context.users.byUsername(filter)
86
+ const grants =
87
+ filter !== '' && member === null ? [] : await recentGrants(context.data, member?.userId ?? null)
88
+ const users = await context.users.standing([
89
+ ...new Set(grants.map((grant) => Number(grant.user_id))),
90
+ ])
91
+ const names = new Map(users.map((user) => [user.userId, user.username]))
92
+ return (
93
+ <div className="flex flex-col gap-4">
94
+ {notice(context, context.query.notice)}
95
+ <form
96
+ method="post"
97
+ action="/admin/api/plugins/awards/grant"
98
+ className={surfaceVariants({ padded: true, className: 'grid gap-3 sm:grid-cols-2' })}
99
+ >
100
+ <label className="flex flex-col gap-1">
101
+ {translated(context, 'awards.award')}
102
+ <select name="award_id" required className={controlVariants()}>
103
+ {awards.map((award) => (
104
+ <option key={award.id} value={award.id}>
105
+ {award.name}
106
+ </option>
107
+ ))}
108
+ </select>
109
+ </label>
110
+ {field(context, 'usernames', 'awards.usernames', '', 'text', true)}
111
+ <label className="flex flex-col gap-1">
112
+ {translated(context, 'awards.reason')}
113
+ <textarea name="reason" maxLength={2000} className={controlVariants()} />
114
+ </label>
115
+ <div>{button(context, 'awards.grant')}</div>
116
+ </form>
117
+ <form method="get" className="flex flex-wrap items-end gap-3">
118
+ {field(context, 'user', 'awards.filter', filter)}
119
+ {button(context, 'awards.filter.apply')}
120
+ </form>
121
+ <div className="overflow-x-auto">
122
+ <table className="w-full text-left text-sm">
123
+ <caption className="text-left font-semibold">
124
+ {translated(context, 'awards.recent')}
125
+ </caption>
126
+ <thead>
127
+ <tr>
128
+ {(
129
+ [
130
+ 'awards.member',
131
+ 'awards.award',
132
+ 'awards.reason',
133
+ 'awards.date',
134
+ 'awards.actions',
135
+ ] as const
136
+ ).map((key) => (
137
+ <th key={key} className="p-2">
138
+ {translated(context, key)}
139
+ </th>
140
+ ))}
141
+ </tr>
142
+ </thead>
143
+ <tbody>
144
+ {grants.map((grant) => (
145
+ <tr key={grant.grant_id} className="border-t">
146
+ <td className="p-2">
147
+ <a href={`/plugins/awards/member?id=${grant.user_id}`}>
148
+ {names.get(Number(grant.user_id)) ??
149
+ translated(context, 'awards.deletedMember')}
150
+ </a>
151
+ </td>
152
+ <td className="p-2">{grant.name}</td>
153
+ <td className="p-2">{grant.reason}</td>
154
+ <td className="p-2">{date(context, grant.granted_at)}</td>
155
+ <td className="p-2">
156
+ {action(context, 'grant', grant.grant_id, 'revoke', 'awards.revoke')}
157
+ </td>
158
+ </tr>
159
+ ))}
160
+ </tbody>
161
+ </table>
162
+ </div>
163
+ </div>
164
+ )
165
+ }