@meith/db 0.16.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.
Files changed (205) hide show
  1. package/LICENSE.md +165 -0
  2. package/migrations/0000_initial_schema.sql +538 -0
  3. package/migrations/0001_seed_usergroups.sql +111 -0
  4. package/migrations/0002_group_promotions.sql +25 -0
  5. package/migrations/0003_counter_maintenance.sql +34 -0
  6. package/migrations/0004_post_render_cache.sql +7 -0
  7. package/migrations/0005_reports.sql +51 -0
  8. package/migrations/0006_warnings.sql +91 -0
  9. package/migrations/0007_notifications.sql +44 -0
  10. package/migrations/0008_subscriptions.sql +48 -0
  11. package/migrations/0009_member_preferences.sql +13 -0
  12. package/migrations/0010_profile_fields.sql +53 -0
  13. package/migrations/0011_private_messages.sql +63 -0
  14. package/migrations/0012_user_relations.sql +18 -0
  15. package/migrations/0013_reputation.sql +45 -0
  16. package/migrations/0014_signatures.sql +16 -0
  17. package/migrations/0015_admin_sessions.sql +24 -0
  18. package/migrations/0016_attachments.sql +51 -0
  19. package/migrations/0017_avatars.sql +33 -0
  20. package/migrations/0018_mass_mail.sql +15 -0
  21. package/migrations/0019_word_filters.sql +8 -0
  22. package/migrations/0020_searches.sql +15 -0
  23. package/migrations/0021_presence_stats.sql +18 -0
  24. package/migrations/0022_api_tokens_webhooks.sql +58 -0
  25. package/migrations/0023_install_state.sql +6 -0
  26. package/migrations/0024_component_versions.sql +13 -0
  27. package/migrations/0025_import.sql +25 -0
  28. package/migrations/0026_polls.sql +40 -0
  29. package/migrations/0027_drafts.sql +13 -0
  30. package/migrations/0028_board_vocabulary.sql +32 -0
  31. package/migrations/0029_announcements.sql +16 -0
  32. package/migrations/0030_antispam.sql +18 -0
  33. package/migrations/0031_markdown.sql +24 -0
  34. package/migrations/0032_theme_enablement.sql +1 -0
  35. package/migrations/0033_group_identity.sql +5 -0
  36. package/migrations/0034_search_document_version.sql +4 -0
  37. package/migrations/0035_categories_hold_no_threads.sql +1 -0
  38. package/migrations/0036_expiring_group_grants.sql +6 -0
  39. package/migrations/0037_automatic_timezone.sql +3 -0
  40. package/migrations/0038_primary_group_grants.sql +3 -0
  41. package/migrations/0039_moderator_log_forum_scope.sql +1 -0
  42. package/migrations/0040_moderator_restore_grants.sql +1 -0
  43. package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
  44. package/migrations/0042_drop_placebo_delete_others.sql +2 -0
  45. package/migrations/0043_federated_identity.sql +28 -0
  46. package/migrations/0044_second_factor.sql +34 -0
  47. package/migrations/0045_drop_dead_task_due_index.sql +1 -0
  48. package/migrations/0046_member_locale.sql +1 -0
  49. package/migrations/0047_navigation_items.sql +34 -0
  50. package/migrations/0048_web_push.sql +16 -0
  51. package/migrations/0049_plugin_health.sql +7 -0
  52. package/migrations/0050_drop_forum_password_grants.sql +1 -0
  53. package/migrations/0051_glossy_barracuda.sql +1 -0
  54. package/migrations/0052_many_professor_monster.sql +2 -0
  55. package/migrations/0053_strange_maximus.sql +14 -0
  56. package/migrations/0054_legacy_import_sources.sql +25 -0
  57. package/migrations/0055_poll_parity.sql +14 -0
  58. package/migrations/0056_marketplace_catalog.sql +11 -0
  59. package/migrations/meta/0000_snapshot.json +4412 -0
  60. package/migrations/meta/0006_snapshot.json +5411 -0
  61. package/migrations/meta/0007_snapshot.json +5634 -0
  62. package/migrations/meta/0008_snapshot.json +5779 -0
  63. package/migrations/meta/0009_snapshot.json +5816 -0
  64. package/migrations/meta/0010_snapshot.json +6117 -0
  65. package/migrations/meta/0011_snapshot.json +6406 -0
  66. package/migrations/meta/0012_snapshot.json +6517 -0
  67. package/migrations/meta/0013_snapshot.json +6722 -0
  68. package/migrations/meta/0014_snapshot.json +6777 -0
  69. package/migrations/meta/0015_snapshot.json +6914 -0
  70. package/migrations/meta/0016_snapshot.json +7192 -0
  71. package/migrations/meta/0017_snapshot.json +7248 -0
  72. package/migrations/meta/0018_snapshot.json +7355 -0
  73. package/migrations/meta/0019_snapshot.json +7419 -0
  74. package/migrations/meta/0020_snapshot.json +7562 -0
  75. package/migrations/meta/0021_snapshot.json +7653 -0
  76. package/migrations/meta/0031_snapshot.json +8162 -0
  77. package/migrations/meta/0032_snapshot.json +8169 -0
  78. package/migrations/meta/0033_snapshot.json +8193 -0
  79. package/migrations/meta/0034_snapshot.json +8221 -0
  80. package/migrations/meta/0036_snapshot.json +8262 -0
  81. package/migrations/meta/0037_snapshot.json +8262 -0
  82. package/migrations/meta/0038_snapshot.json +8293 -0
  83. package/migrations/meta/0039_snapshot.json +8308 -0
  84. package/migrations/meta/0041_snapshot.json +8287 -0
  85. package/migrations/meta/0042_snapshot.json +8274 -0
  86. package/migrations/meta/0043_snapshot.json +8521 -0
  87. package/migrations/meta/0044_snapshot.json +8814 -0
  88. package/migrations/meta/0045_snapshot.json +8797 -0
  89. package/migrations/meta/0046_snapshot.json +8804 -0
  90. package/migrations/meta/0047_snapshot.json +9018 -0
  91. package/migrations/meta/0048_snapshot.json +9145 -0
  92. package/migrations/meta/0049_snapshot.json +9190 -0
  93. package/migrations/meta/0050_snapshot.json +9126 -0
  94. package/migrations/meta/0051_snapshot.json +9132 -0
  95. package/migrations/meta/0052_snapshot.json +9148 -0
  96. package/migrations/meta/0053_snapshot.json +9268 -0
  97. package/migrations/meta/0054_snapshot.json +9378 -0
  98. package/migrations/meta/0056_snapshot.json +9447 -0
  99. package/migrations/meta/_journal.json +405 -0
  100. package/package.json +64 -0
  101. package/src/account-repos.ts +580 -0
  102. package/src/actor-builder.ts +130 -0
  103. package/src/admin-repo.ts +85 -0
  104. package/src/admin-session-repo.ts +205 -0
  105. package/src/admin-undo-repo.ts +128 -0
  106. package/src/announcement-repo.ts +176 -0
  107. package/src/antispam-repo.ts +145 -0
  108. package/src/api-repo.ts +276 -0
  109. package/src/attachment-admin-repo.ts +161 -0
  110. package/src/attachment-repo.ts +240 -0
  111. package/src/authorization-source.ts +138 -0
  112. package/src/avatar-repo.ts +185 -0
  113. package/src/ban-repos.ts +146 -0
  114. package/src/client.ts +85 -0
  115. package/src/content-admin-repo.ts +298 -0
  116. package/src/content-counters.ts +152 -0
  117. package/src/counter-recount.ts +263 -0
  118. package/src/cursor.ts +16 -0
  119. package/src/denormalised-username.ts +34 -0
  120. package/src/discovery-repo.ts +140 -0
  121. package/src/draft-repo.ts +59 -0
  122. package/src/feed-repo.ts +219 -0
  123. package/src/forum-admin-repo.ts +310 -0
  124. package/src/forum-repo.ts +206 -0
  125. package/src/group-admin-repo.ts +256 -0
  126. package/src/group-identity-repo.ts +84 -0
  127. package/src/identity-link-repo.ts +145 -0
  128. package/src/import-repo.ts +123 -0
  129. package/src/import-sink.ts +1377 -0
  130. package/src/index.ts +316 -0
  131. package/src/inline-moderation.ts +344 -0
  132. package/src/install-repo.ts +67 -0
  133. package/src/latest-repo.ts +115 -0
  134. package/src/maintenance-repo.ts +54 -0
  135. package/src/marketplace-repo.ts +111 -0
  136. package/src/member-profile-repo.ts +82 -0
  137. package/src/member-settings-repo.ts +160 -0
  138. package/src/message-repo.ts +384 -0
  139. package/src/migrate-run.ts +18 -0
  140. package/src/migrate.ts +129 -0
  141. package/src/modcp-repo.ts +253 -0
  142. package/src/moderation-queue.ts +279 -0
  143. package/src/navigation-repo.ts +288 -0
  144. package/src/notification-repo.ts +402 -0
  145. package/src/orphan-keys.ts +19 -0
  146. package/src/outbox-repo.ts +78 -0
  147. package/src/permission-version.ts +24 -0
  148. package/src/permissions-map.ts +52 -0
  149. package/src/pglite.fixture.ts +87 -0
  150. package/src/plugin-data.ts +88 -0
  151. package/src/plugin-grants.ts +369 -0
  152. package/src/plugin-health-repo.ts +80 -0
  153. package/src/plugin-purge-repo.ts +91 -0
  154. package/src/plugin-users.ts +39 -0
  155. package/src/poll-repo.ts +334 -0
  156. package/src/post-repo.ts +261 -0
  157. package/src/post-writes.ts +227 -0
  158. package/src/presence-repo.ts +220 -0
  159. package/src/profile-field-repo.ts +182 -0
  160. package/src/promotion-repo.ts +152 -0
  161. package/src/read-state-repo.ts +81 -0
  162. package/src/relation-repo.ts +113 -0
  163. package/src/render-backfill.ts +110 -0
  164. package/src/render-signature.ts +33 -0
  165. package/src/report-repo.ts +349 -0
  166. package/src/reputation-repo.ts +295 -0
  167. package/src/result-rows.ts +13 -0
  168. package/src/row-values.ts +7 -0
  169. package/src/schema/content.ts +451 -0
  170. package/src/schema/identity.ts +699 -0
  171. package/src/schema/index.ts +10 -0
  172. package/src/schema/messages.ts +76 -0
  173. package/src/schema/permission-columns.ts +34 -0
  174. package/src/schema/platform.ts +376 -0
  175. package/src/schema/structure.ts +164 -0
  176. package/src/search-repo.ts +359 -0
  177. package/src/search-store.ts +113 -0
  178. package/src/seed-groups.ts +11 -0
  179. package/src/settings-repo.ts +32 -0
  180. package/src/signature-repo.ts +102 -0
  181. package/src/sql-lists.ts +17 -0
  182. package/src/staff-groups.ts +50 -0
  183. package/src/stats-repo.ts +151 -0
  184. package/src/subscription-repo.ts +350 -0
  185. package/src/system-health-repo.ts +130 -0
  186. package/src/task-repo.ts +104 -0
  187. package/src/theme-admin-repo.ts +172 -0
  188. package/src/theme-repo.ts +43 -0
  189. package/src/thread-audience.ts +45 -0
  190. package/src/thread-counters.ts +107 -0
  191. package/src/thread-repo.ts +255 -0
  192. package/src/thread-surgery.ts +282 -0
  193. package/src/thread-tools.ts +311 -0
  194. package/src/thread-views.ts +41 -0
  195. package/src/thread-writes.ts +356 -0
  196. package/src/two-factor-repo.ts +235 -0
  197. package/src/upgrade-repo.ts +66 -0
  198. package/src/user-admin-repo.ts +367 -0
  199. package/src/user-bulk-repo.ts +397 -0
  200. package/src/user-merge-map.ts +111 -0
  201. package/src/user-merge-repo.ts +304 -0
  202. package/src/visibility-counters.ts +190 -0
  203. package/src/visibility.ts +17 -0
  204. package/src/vocabulary-repo.ts +54 -0
  205. package/src/warning-repo.ts +328 -0
@@ -0,0 +1,253 @@
1
+ import { type SQL, sql } from 'drizzle-orm'
2
+
3
+ import type { IpMatch, ModCpRepository, ModLogEntry, ModLogPage } from '@meith/moderation'
4
+ import { MOD_LOG_ACTIONS } from '@meith/moderation'
5
+
6
+ import type { Database } from './client'
7
+ import { decodeCursor, encodeCursor } from './cursor'
8
+ import { resultRows } from './result-rows'
9
+ import { idList, textList } from './sql-lists'
10
+ import { PENDING_APPROVAL } from './visibility'
11
+
12
+ interface LogRow {
13
+ id: number
14
+ action: string
15
+ user_id: number | null
16
+ username: string | null
17
+ forum_id: number | null
18
+ forum_title: string | null
19
+ detail: Record<string, unknown> | null
20
+ created_at: Date
21
+ }
22
+
23
+ const DETAIL_LABELS: Readonly<Record<string, string>> = {
24
+ threadId: 'Thread',
25
+ threadIds: 'Threads',
26
+ targetThreadId: 'Into thread',
27
+ newThreadId: 'New thread',
28
+ postId: 'Post',
29
+ postIds: 'Posts',
30
+ forumId: 'Forum',
31
+ fromForumId: 'From forum',
32
+ toForumId: 'To forum',
33
+ applied: 'Applied to',
34
+ posts: 'Posts affected',
35
+ userId: 'Member',
36
+ warningId: 'Warning',
37
+ points: 'Points',
38
+ total: 'New total',
39
+ matches: 'Accounts found',
40
+ subjectUserId: 'Member',
41
+ reportId: 'Report',
42
+ value: 'Set to',
43
+ }
44
+
45
+ function flattenDetail(
46
+ detail: Record<string, unknown> | null,
47
+ ): readonly { label: string; value: string }[] {
48
+ if (detail === null) return []
49
+ const out: { label: string; value: string }[] = []
50
+ for (const [key, label] of Object.entries(DETAIL_LABELS)) {
51
+ const value = detail[key]
52
+ if (value === undefined || value === null) continue
53
+ const rendered = Array.isArray(value)
54
+ ? value.slice(0, 20).map(String).join(', ')
55
+ : String(value)
56
+ if (rendered === '') continue
57
+ out.push({ label, value: rendered })
58
+ }
59
+ return out
60
+ }
61
+
62
+ function forumScope(forumIds: readonly number[]): SQL {
63
+ if (forumIds.length === 0) return sql`false`
64
+ return sql.join(
65
+ forumIds.map(
66
+ (id) => sql`(
67
+ l.detail->'forumIds' @> ${String(id)}::jsonb
68
+ or l.detail->'forumId' = ${String(id)}::jsonb
69
+ or l.detail->'toForumId' = ${String(id)}::jsonb
70
+ )`,
71
+ ),
72
+ sql` or `,
73
+ )
74
+ }
75
+
76
+ export class PostgresModCpRepository implements ModCpRepository {
77
+ constructor(private readonly db: Database) {}
78
+
79
+ async log(input: {
80
+ readonly forumIds: readonly number[]
81
+ readonly actorUserId: number
82
+ readonly limit: number
83
+ readonly after?: string | undefined
84
+ }): Promise<ModLogPage> {
85
+ const cursor = input.after === undefined ? null : decodeCursor(input.after)
86
+ const after = cursor ? sql`and (e.created_at, e.id) < (${cursor.at}, ${cursor.id})` : sql``
87
+
88
+ const rows = resultRows(
89
+ await this.db.execute(sql`
90
+ with entry as (
91
+ select l.id, l.action, l.user_id, l.created_at, l.detail,
92
+ coalesce(
93
+ (l.detail->>'forumId')::int,
94
+ (l.detail->>'toForumId')::int
95
+ ) as forum_id
96
+ from admin_log l
97
+ where l.action in ${textList(MOD_LOG_ACTIONS)}
98
+ and (
99
+ l.user_id = ${input.actorUserId}
100
+ or ${forumScope(input.forumIds)}
101
+ )
102
+ )
103
+ select e.id, e.action, e.user_id, u.username, e.forum_id,
104
+ f.title as forum_title, e.detail, e.created_at
105
+ from entry e
106
+ left join users u on u.id = e.user_id
107
+ left join forums f on f.id = e.forum_id
108
+ where true ${after}
109
+ order by e.created_at desc, e.id desc
110
+ limit ${input.limit + 1}
111
+ `),
112
+ ) as LogRow[]
113
+
114
+ const entries = rows.slice(0, input.limit).map(
115
+ (row): ModLogEntry => ({
116
+ id: Number(row.id),
117
+ action: row.action,
118
+ actorUserId: row.user_id === null ? null : Number(row.user_id),
119
+ actorUsername: row.username,
120
+ forumId: row.forum_id === null ? null : Number(row.forum_id),
121
+ forumTitle: row.forum_title,
122
+ detail: flattenDetail(row.detail),
123
+ at: new Date(row.created_at),
124
+ }),
125
+ )
126
+ const last = entries.at(-1)
127
+ return rows.length > input.limit && last
128
+ ? { entries, nextCursor: encodeCursor(last.at, last.id) }
129
+ : { entries }
130
+ }
131
+
132
+ async workload(
133
+ forumIds: readonly number[],
134
+ ): Promise<ReadonlyMap<number, { pending: number; openReports: number }>> {
135
+ if (forumIds.length === 0) return new Map()
136
+
137
+ const rows = resultRows(
138
+ await this.db.execute(sql`
139
+ with held as (
140
+ select forum_id, count(*)::int as n from threads
141
+ where forum_id in ${idList(forumIds)} and visibility = ${PENDING_APPROVAL}
142
+ group by forum_id
143
+ union all
144
+ select p.forum_id, count(*)::int from posts p
145
+ join threads t on t.id = p.thread_id
146
+ where p.forum_id in ${idList(forumIds)}
147
+ and p.visibility = ${PENDING_APPROVAL}
148
+ and t.visibility <> ${PENDING_APPROVAL}
149
+ and p.is_first_post = false
150
+ group by p.forum_id
151
+ ),
152
+ reported as (
153
+ select forum_id, count(*)::int as n from reports
154
+ where forum_id in ${idList(forumIds)} and status = 'open'
155
+ group by forum_id
156
+ )
157
+ select f.id as forum_id,
158
+ coalesce((select sum(n) from held where held.forum_id = f.id), 0)::int as pending,
159
+ coalesce((select n from reported where reported.forum_id = f.id), 0)::int as open_reports
160
+ from forums f
161
+ where f.id in ${idList(forumIds)}
162
+ `),
163
+ ) as Array<{ forum_id: number; pending: number; open_reports: number }>
164
+
165
+ return new Map(
166
+ rows.map((row) => [
167
+ Number(row.forum_id),
168
+ { pending: Number(row.pending), openReports: Number(row.open_reports) },
169
+ ]),
170
+ )
171
+ }
172
+
173
+ async ipPrefixesFor(
174
+ userId: number,
175
+ ): Promise<{ registration: string | null; lastVisit: string | null }> {
176
+ const rows = resultRows(
177
+ await this.db.execute(sql`
178
+ select registration_ip_prefix, last_ip_prefix from users where id = ${userId}
179
+ `),
180
+ ) as Array<{ registration_ip_prefix: string | null; last_ip_prefix: string | null }>
181
+ const row = rows[0]
182
+ if (!row) return { registration: null, lastVisit: null }
183
+ return { registration: row.registration_ip_prefix, lastVisit: row.last_ip_prefix }
184
+ }
185
+
186
+ async ipMatches(userId: number, limit: number): Promise<readonly IpMatch[]> {
187
+ const rows = resultRows(
188
+ await this.db.execute(sql`
189
+ with subject as (
190
+ select registration_ip_prefix as reg, last_ip_prefix as last
191
+ from users where id = ${userId}
192
+ )
193
+ select u.id, u.username, u.last_active_at,
194
+ (u.registration_ip_prefix is not null
195
+ and u.registration_ip_prefix in (
196
+ (select reg from subject), (select last from subject)
197
+ )) as by_registration,
198
+ (u.last_ip_prefix is not null
199
+ and u.last_ip_prefix in (
200
+ (select reg from subject), (select last from subject)
201
+ )) as by_last
202
+ from users u, subject s
203
+ where u.id <> ${userId}
204
+ and u.state <> 'deleted'
205
+ and (
206
+ (u.registration_ip_prefix is not null
207
+ and u.registration_ip_prefix in (s.reg, s.last))
208
+ or (u.last_ip_prefix is not null and u.last_ip_prefix in (s.reg, s.last))
209
+ )
210
+ order by u.last_active_at desc nulls last, u.id
211
+ limit ${limit}
212
+ `),
213
+ ) as Array<{
214
+ id: number
215
+ username: string
216
+ last_active_at: Date | null
217
+ by_registration: boolean
218
+ by_last: boolean
219
+ }>
220
+
221
+ return rows.map((row) => ({
222
+ userId: Number(row.id),
223
+ username: row.username,
224
+ matchedOn:
225
+ row.by_registration && row.by_last
226
+ ? 'both'
227
+ : row.by_registration
228
+ ? 'registration'
229
+ : 'last_visit',
230
+ lastActiveAt: row.last_active_at === null ? null : new Date(row.last_active_at),
231
+ }))
232
+ }
233
+
234
+ async recordIpLookup(input: {
235
+ readonly actorUserId: number
236
+ readonly subjectUserId: number
237
+ readonly matches: number
238
+ readonly at: Date
239
+ }): Promise<void> {
240
+ await this.db.execute(sql`
241
+ insert into admin_log (user_id, action, detail, created_at)
242
+ values (
243
+ ${input.actorUserId},
244
+ 'modcp.ip_lookup',
245
+ ${JSON.stringify({
246
+ subjectUserId: input.subjectUserId,
247
+ matches: input.matches,
248
+ })}::jsonb,
249
+ ${input.at}
250
+ )
251
+ `)
252
+ }
253
+ }
@@ -0,0 +1,279 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ ModerationQueueRepository,
5
+ PendingItem,
6
+ QueueDecision,
7
+ QueueItem,
8
+ QueuePage,
9
+ QueueSelection,
10
+ } from '@meith/moderation'
11
+
12
+ import type { Database } from './client'
13
+ import { resultRows } from './result-rows'
14
+ import { idList } from './sql-lists'
15
+ import { PENDING_APPROVAL, VISIBLE } from './visibility'
16
+ import { applyVisibilityChangeCounters } from './visibility-counters'
17
+
18
+ const EXCERPT = 300
19
+
20
+ interface QueueRow {
21
+ kind: 'thread' | 'post'
22
+ id: number
23
+ forum_id: number
24
+ forum_title: string
25
+ thread_id: number
26
+ thread_slug: string
27
+ thread_title: string
28
+ author_user_id: number | null
29
+ author_username: string
30
+ excerpt: string | null
31
+ created_at: Date
32
+ }
33
+
34
+ function encodeCursor(row: QueueItem): string {
35
+ return Buffer.from(`${row.createdAt.toISOString()}|${row.kind}|${row.id}`, 'utf8').toString(
36
+ 'base64url',
37
+ )
38
+ }
39
+
40
+ function decodeCursor(value: string): { at: Date; kind: string; id: number } | null {
41
+ try {
42
+ const [at, kind, id] = Buffer.from(value, 'base64url').toString('utf8').split('|')
43
+ if (at === undefined || kind === undefined || id === undefined) return null
44
+ const when = new Date(at)
45
+ const numeric = Number(id)
46
+ if (Number.isNaN(when.getTime()) || !Number.isSafeInteger(numeric)) return null
47
+ return { at: when, kind, id: numeric }
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ function toItem(row: QueueRow): QueueItem {
54
+ return {
55
+ kind: row.kind,
56
+ id: Number(row.id),
57
+ forumId: Number(row.forum_id),
58
+ forumTitle: row.forum_title,
59
+ threadId: Number(row.thread_id),
60
+ threadSlug: row.thread_slug,
61
+ threadTitle: row.thread_title,
62
+ authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
63
+ authorUsername: row.author_username,
64
+ excerpt: row.excerpt ?? '',
65
+ createdAt: new Date(row.created_at),
66
+ }
67
+ }
68
+
69
+ export class PostgresModerationQueueRepository implements ModerationQueueRepository {
70
+ constructor(private readonly db: Database) {}
71
+
72
+ async list(
73
+ forumIds: readonly number[],
74
+ options: {
75
+ readonly limit: number
76
+ readonly after?: string
77
+ readonly offset?: number
78
+ },
79
+ ): Promise<QueuePage> {
80
+ if (forumIds.length === 0) return { items: [] }
81
+
82
+ const cursor = options.after === undefined ? null : decodeCursor(options.after)
83
+ const after = cursor
84
+ ? sql`and (q.created_at, q.kind, q.id) > (${cursor.at}, ${cursor.kind}, ${cursor.id})`
85
+ : sql``
86
+
87
+ const rows = resultRows(
88
+ await this.db.execute(sql`
89
+ with q as (
90
+ select 'thread'::text as kind, t.id, t.forum_id, f.title as forum_title,
91
+ t.id as thread_id, t.slug as thread_slug, t.title as thread_title,
92
+ t.author_user_id, t.author_username,
93
+ left(p.message, ${EXCERPT}) as excerpt, t.created_at
94
+ from threads t
95
+ join forums f on f.id = t.forum_id
96
+ left join posts p on p.id = t.first_post_id
97
+ where t.forum_id in ${idList(forumIds)}
98
+ and t.visibility = ${PENDING_APPROVAL}
99
+ union all
100
+ select 'post'::text, p.id, p.forum_id, f.title,
101
+ p.thread_id, t.slug, t.title,
102
+ p.author_user_id, p.author_username,
103
+ left(p.message, ${EXCERPT}), p.created_at
104
+ from posts p
105
+ join threads t on t.id = p.thread_id
106
+ join forums f on f.id = p.forum_id
107
+ where p.forum_id in ${idList(forumIds)}
108
+ and p.visibility = ${PENDING_APPROVAL}
109
+ and t.visibility = ${VISIBLE}
110
+ and p.is_first_post = false
111
+ )
112
+ select * from q
113
+ where true ${after}
114
+ order by q.created_at, q.kind, q.id
115
+ limit ${options.limit + 1} offset ${options.offset ?? 0}
116
+ `),
117
+ ) as QueueRow[]
118
+
119
+ const items = rows.slice(0, options.limit).map(toItem)
120
+ const last = items.at(-1)
121
+ return rows.length > options.limit && last
122
+ ? { items, nextCursor: encodeCursor(last) }
123
+ : { items }
124
+ }
125
+
126
+ async countPending(forumIds: readonly number[]): Promise<number> {
127
+ if (forumIds.length === 0) return 0
128
+ const rows = resultRows(
129
+ await this.db.execute(sql`
130
+ select
131
+ (select count(*) from threads
132
+ where forum_id in ${idList(forumIds)} and visibility = ${PENDING_APPROVAL})
133
+ +
134
+ (select count(*) from posts p
135
+ join threads t on t.id = p.thread_id
136
+ where p.forum_id in ${idList(forumIds)}
137
+ and p.visibility = ${PENDING_APPROVAL}
138
+ and t.visibility = ${VISIBLE}
139
+ and p.is_first_post = false)
140
+ as pending
141
+ `),
142
+ ) as Array<{ pending: number }>
143
+ return Number(rows[0]?.pending ?? 0)
144
+ }
145
+
146
+ async resolve(selection: readonly QueueSelection[]): Promise<readonly PendingItem[]> {
147
+ const threadIds = selection.filter((s) => s.kind === 'thread').map((s) => s.id)
148
+ const postIds = selection.filter((s) => s.kind === 'post').map((s) => s.id)
149
+ if (threadIds.length === 0 && postIds.length === 0) return []
150
+
151
+ const rows = resultRows(
152
+ await this.db.execute(sql`
153
+ select 'thread'::text as kind, id, forum_id from threads
154
+ where id in ${idList(threadIds)} and visibility = ${PENDING_APPROVAL}
155
+ union all
156
+ select 'post'::text, p.id, p.forum_id from posts p
157
+ join threads t on t.id = p.thread_id
158
+ where p.id in ${idList(postIds)}
159
+ and p.visibility = ${PENDING_APPROVAL}
160
+ and t.visibility = ${VISIBLE}
161
+ `),
162
+ ) as Array<{ kind: 'thread' | 'post'; id: number; forum_id: number }>
163
+
164
+ return rows.map((row) => ({
165
+ kind: row.kind,
166
+ id: Number(row.id),
167
+ forumId: Number(row.forum_id),
168
+ }))
169
+ }
170
+
171
+ async apply(input: {
172
+ readonly decision: QueueDecision
173
+ readonly threadIds: readonly number[]
174
+ readonly postIds: readonly number[]
175
+ readonly actorUserId: number
176
+ readonly at: Date
177
+ }): Promise<number> {
178
+ if (input.threadIds.length === 0 && input.postIds.length === 0) return 0
179
+ const approving = input.decision === 'approve'
180
+ const to = approving ? 'visible' : 'deleted'
181
+
182
+ return this.db.transaction(async (tx) => {
183
+ let applied = 0
184
+ const touched = new Set<number>()
185
+
186
+ for (const threadId of input.threadIds) {
187
+ const moved = resultRows(
188
+ await tx.execute(sql`
189
+ update threads set visibility = ${to}, updated_at = now()
190
+ where id = ${threadId} and visibility = ${PENDING_APPROVAL}
191
+ returning id, forum_id, first_post_id, author_user_id
192
+ `),
193
+ ) as Array<{
194
+ id: number
195
+ forum_id: number
196
+ first_post_id: number | null
197
+ author_user_id: number | null
198
+ }>
199
+ const thread = moved[0]
200
+ if (!thread) continue
201
+ applied += 1
202
+ touched.add(Number(thread.forum_id))
203
+
204
+ if (thread.first_post_id !== null) {
205
+ const post = resultRows(
206
+ await tx.execute(sql`
207
+ update posts set visibility = ${to}
208
+ where id = ${thread.first_post_id} and visibility = ${PENDING_APPROVAL}
209
+ returning id
210
+ `),
211
+ ) as Array<{ id: number }>
212
+
213
+ if (post[0] && approving) {
214
+ await applyVisibilityChangeCounters(tx, {
215
+ postId: Number(post[0].id),
216
+ threadId: Number(thread.id),
217
+ forumId: Number(thread.forum_id),
218
+ authorId: thread.author_user_id === null ? null : Number(thread.author_user_id),
219
+ isFirstPost: true,
220
+ delta: 1,
221
+ })
222
+ }
223
+ }
224
+ }
225
+
226
+ for (const postId of input.postIds) {
227
+ const moved = resultRows(
228
+ await tx.execute(sql`
229
+ update posts set visibility = ${to}
230
+ where id = ${postId} and visibility = ${PENDING_APPROVAL}
231
+ returning id, thread_id, forum_id, author_user_id, is_first_post
232
+ `),
233
+ ) as Array<{
234
+ id: number
235
+ thread_id: number
236
+ forum_id: number
237
+ author_user_id: number | null
238
+ is_first_post: boolean
239
+ }>
240
+ const post = moved[0]
241
+ if (!post) continue
242
+ applied += 1
243
+ touched.add(Number(post.forum_id))
244
+
245
+ if (approving) {
246
+ await applyVisibilityChangeCounters(tx, {
247
+ postId: Number(post.id),
248
+ threadId: Number(post.thread_id),
249
+ forumId: Number(post.forum_id),
250
+ authorId: post.author_user_id === null ? null : Number(post.author_user_id),
251
+ isFirstPost: post.is_first_post,
252
+ delta: 1,
253
+ })
254
+ }
255
+ }
256
+
257
+ if (applied > 0) {
258
+ const forumIds = [...touched]
259
+ await tx.execute(sql`
260
+ insert into admin_log (user_id, action, detail, created_at)
261
+ values (
262
+ ${input.actorUserId},
263
+ ${`moderation.${input.decision}`},
264
+ ${JSON.stringify({
265
+ threadIds: input.threadIds,
266
+ postIds: input.postIds,
267
+ ...(forumIds.length === 1 ? { forumId: forumIds[0] } : {}),
268
+ forumIds,
269
+ applied,
270
+ })}::jsonb,
271
+ ${input.at}
272
+ )
273
+ `)
274
+ }
275
+
276
+ return applied
277
+ })
278
+ }
279
+ }