@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,349 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { PUBLIC_CONTENT } from '@meith/core'
4
+ import type {
5
+ NewReport,
6
+ ReportEvent,
7
+ ReportPage,
8
+ ReportRepository,
9
+ ReportRow,
10
+ ReportScope,
11
+ ReportTarget,
12
+ ReportTargetKind,
13
+ } from '@meith/moderation'
14
+
15
+ import type { Database } from './client'
16
+ import { decodeCursor, encodeCursor } from './cursor'
17
+ import { resultRows } from './result-rows'
18
+ import { threads } from './schema'
19
+ import { idList } from './sql-lists'
20
+ import { logModeratorAction } from './thread-counters'
21
+ import { visibleIn } from './visibility'
22
+
23
+ interface RawReport {
24
+ id: number
25
+ target_kind: ReportTargetKind
26
+ target_id: number
27
+ forum_id: number | null
28
+ thread_id: number | null
29
+ target_label: string
30
+ reporter_user_id: number | null
31
+ reporter_username: string | null
32
+ reason: string
33
+ status: ReportRow['status']
34
+ assigned_to_user_id: number | null
35
+ assigned_username: string | null
36
+ created_at: Date
37
+ }
38
+
39
+ function toReport(row: RawReport): ReportRow {
40
+ return {
41
+ id: Number(row.id),
42
+ kind: row.target_kind,
43
+ targetId: Number(row.target_id),
44
+ forumId: row.forum_id === null ? null : Number(row.forum_id),
45
+ threadId: row.thread_id === null ? null : Number(row.thread_id),
46
+ targetLabel: row.target_label,
47
+ reporterUserId: row.reporter_user_id === null ? null : Number(row.reporter_user_id),
48
+ reporterUsername: row.reporter_username,
49
+ reason: row.reason,
50
+ status: row.status,
51
+ assignedToUserId: row.assigned_to_user_id === null ? null : Number(row.assigned_to_user_id),
52
+ assignedToUsername: row.assigned_username,
53
+ createdAt: new Date(row.created_at),
54
+ }
55
+ }
56
+
57
+ const SELECT_REPORT = sql`
58
+ select r.id, r.target_kind, r.target_id, r.forum_id, r.thread_id, r.target_label,
59
+ r.reporter_user_id, reporter.username as reporter_username,
60
+ r.reason, r.status, r.assigned_to_user_id,
61
+ assignee.username as assigned_username, r.created_at
62
+ from reports r
63
+ left join users reporter on reporter.id = r.reporter_user_id
64
+ left join users assignee on assignee.id = r.assigned_to_user_id
65
+ `
66
+
67
+ export class PostgresReportRepository implements ReportRepository {
68
+ constructor(private readonly db: Database) {}
69
+
70
+ async resolveTarget(
71
+ kind: ReportTargetKind,
72
+ id: number,
73
+ reporterUserId: number,
74
+ ): Promise<ReportTarget | null> {
75
+ if (kind === 'private_message') {
76
+ const rows = resultRows(
77
+ await this.db.execute(sql`
78
+ select m.id, m.subject
79
+ from private_messages m
80
+ join private_message_copies c
81
+ on c.message_id = m.id and c.owner_user_id = ${reporterUserId}
82
+ where m.id = ${id}
83
+ `),
84
+ ) as Array<{ id: number; subject: string }>
85
+ const row = rows[0]
86
+ return row === undefined
87
+ ? null
88
+ : {
89
+ kind,
90
+ id: Number(row.id),
91
+ forumId: null,
92
+ threadId: null,
93
+ threadAuthorUserId: null,
94
+ label: row.subject,
95
+ }
96
+ }
97
+
98
+ if (kind === 'user') {
99
+ const rows = resultRows(
100
+ await this.db.execute(sql`
101
+ select id, username from users where id = ${id} and deleted_at is null
102
+ `),
103
+ ) as Array<{ id: number; username: string }>
104
+ const row = rows[0]
105
+ return row === undefined
106
+ ? null
107
+ : {
108
+ kind,
109
+ id: Number(row.id),
110
+ forumId: null,
111
+ threadId: null,
112
+ threadAuthorUserId: null,
113
+ label: row.username,
114
+ }
115
+ }
116
+
117
+ if (kind === 'thread') {
118
+ const rows = resultRows(
119
+ await this.db.execute(sql`
120
+ select id, forum_id, author_user_id, title from threads
121
+ where id = ${id} and ${visibleIn(threads.visibility, PUBLIC_CONTENT)}
122
+ `),
123
+ ) as Array<{
124
+ id: number
125
+ forum_id: number
126
+ author_user_id: number | null
127
+ title: string
128
+ }>
129
+ const row = rows[0]
130
+ return row === undefined
131
+ ? null
132
+ : {
133
+ kind,
134
+ id: Number(row.id),
135
+ forumId: Number(row.forum_id),
136
+ threadId: Number(row.id),
137
+ threadAuthorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
138
+ label: row.title,
139
+ }
140
+ }
141
+
142
+ const rows = resultRows(
143
+ await this.db.execute(sql`
144
+ select p.id, p.forum_id, p.thread_id, t.author_user_id, t.title
145
+ from posts p
146
+ join threads t on t.id = p.thread_id
147
+ where p.id = ${id}
148
+ and ${visibleIn(sql`p.visibility`, PUBLIC_CONTENT)}
149
+ and ${visibleIn(sql`t.visibility`, PUBLIC_CONTENT)}
150
+ `),
151
+ ) as Array<{
152
+ id: number
153
+ forum_id: number
154
+ thread_id: number
155
+ author_user_id: number | null
156
+ title: string
157
+ }>
158
+ const row = rows[0]
159
+ return row === undefined
160
+ ? null
161
+ : {
162
+ kind,
163
+ id: Number(row.id),
164
+ forumId: Number(row.forum_id),
165
+ threadId: Number(row.thread_id),
166
+ threadAuthorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
167
+ label: row.title,
168
+ }
169
+ }
170
+
171
+ async open(report: NewReport): Promise<number | null> {
172
+ return this.db.transaction(async (tx) => {
173
+ const inserted = resultRows(
174
+ await tx.execute(sql`
175
+ insert into reports
176
+ (target_kind, target_id, forum_id, thread_id, target_label,
177
+ reporter_user_id, reason, status, created_at, updated_at)
178
+ values
179
+ (${report.target.kind}, ${report.target.id}, ${report.target.forumId},
180
+ ${report.target.threadId}, ${report.target.label},
181
+ ${report.reporterUserId}, ${report.reason}, 'open',
182
+ ${report.at}, ${report.at})
183
+ on conflict do nothing
184
+ returning id
185
+ `),
186
+ ) as Array<{ id: number }>
187
+
188
+ const row = inserted[0]
189
+ if (!row) return null
190
+
191
+ await tx.execute(sql`
192
+ insert into report_events (report_id, actor_user_id, kind, created_at)
193
+ values (${row.id}, ${report.reporterUserId}, 'opened', ${report.at})
194
+ `)
195
+ return Number(row.id)
196
+ })
197
+ }
198
+
199
+ private scopePredicate(scope: ReportScope): ReturnType<typeof sql> {
200
+ const byForum = sql`r.forum_id in ${idList(scope.forumIds)}`
201
+ return scope.global ? sql`(${byForum} or r.forum_id is null)` : byForum
202
+ }
203
+
204
+ async listOpen(
205
+ scope: ReportScope,
206
+ options: {
207
+ readonly limit: number
208
+ readonly after?: string
209
+ readonly offset?: number
210
+ },
211
+ ): Promise<ReportPage> {
212
+ const cursor = options.after === undefined ? null : decodeCursor(options.after)
213
+ const after = cursor ? sql`and (r.created_at, r.id) > (${cursor.at}, ${cursor.id})` : sql``
214
+
215
+ const rows = resultRows(
216
+ await this.db.execute(sql`
217
+ ${SELECT_REPORT}
218
+ where r.status = 'open' and ${this.scopePredicate(scope)} ${after}
219
+ order by r.created_at, r.id
220
+ limit ${options.limit + 1} offset ${options.offset ?? 0}
221
+ `),
222
+ ) as RawReport[]
223
+
224
+ const page = rows.slice(0, options.limit).map(toReport)
225
+ const last = page.at(-1)
226
+ return rows.length > options.limit && last
227
+ ? { rows: page, nextCursor: encodeCursor(last.createdAt, last.id) }
228
+ : { rows: page }
229
+ }
230
+
231
+ async countOpen(scope: ReportScope): Promise<number> {
232
+ const rows = resultRows(
233
+ await this.db.execute(sql`
234
+ select count(*)::int as open from reports r
235
+ where r.status = 'open' and ${this.scopePredicate(scope)}
236
+ `),
237
+ ) as Array<{ open: number }>
238
+ return Number(rows[0]?.open ?? 0)
239
+ }
240
+
241
+ async find(id: number): Promise<{ report: ReportRow; events: readonly ReportEvent[] } | null> {
242
+ const rows = resultRows(
243
+ await this.db.execute(sql`${SELECT_REPORT} where r.id = ${id}`),
244
+ ) as RawReport[]
245
+ const row = rows[0]
246
+ if (!row) return null
247
+
248
+ const events = resultRows(
249
+ await this.db.execute(sql`
250
+ select e.id, e.kind, e.actor_user_id, u.username as actor_username,
251
+ e.note, e.created_at
252
+ from report_events e
253
+ left join users u on u.id = e.actor_user_id
254
+ where e.report_id = ${id}
255
+ order by e.created_at, e.id
256
+ `),
257
+ ) as Array<{
258
+ id: number
259
+ kind: ReportEvent['kind']
260
+ actor_user_id: number | null
261
+ actor_username: string | null
262
+ note: string | null
263
+ created_at: Date
264
+ }>
265
+
266
+ return {
267
+ report: toReport(row),
268
+ events: events.map((event) => ({
269
+ id: Number(event.id),
270
+ kind: event.kind,
271
+ actorUserId: event.actor_user_id === null ? null : Number(event.actor_user_id),
272
+ actorUsername: event.actor_username,
273
+ note: event.note,
274
+ createdAt: new Date(event.created_at),
275
+ })),
276
+ }
277
+ }
278
+
279
+ async assign(input: {
280
+ readonly reportId: number
281
+ readonly toUserId: number | null
282
+ readonly actorUserId: number
283
+ readonly at: Date
284
+ }): Promise<boolean> {
285
+ return this.db.transaction(async (tx) => {
286
+ const moved = resultRows(
287
+ await tx.execute(sql`
288
+ update reports
289
+ set assigned_to_user_id = ${input.toUserId}, updated_at = ${input.at}
290
+ where id = ${input.reportId} and status = 'open'
291
+ returning id
292
+ `),
293
+ ) as Array<{ id: number }>
294
+ if (moved.length === 0) return false
295
+
296
+ await tx.execute(sql`
297
+ insert into report_events (report_id, actor_user_id, kind, created_at)
298
+ values (
299
+ ${input.reportId}, ${input.actorUserId},
300
+ ${input.toUserId === null ? 'unassigned' : 'assigned'}, ${input.at}
301
+ )
302
+ `)
303
+ return true
304
+ })
305
+ }
306
+
307
+ async close(input: {
308
+ readonly reportId: number
309
+ readonly status: 'resolved' | 'rejected'
310
+ readonly note: string | null
311
+ readonly actorUserId: number
312
+ readonly at: Date
313
+ }): Promise<boolean> {
314
+ return this.db.transaction(async (tx) => {
315
+ const moved = resultRows(
316
+ await tx.execute(sql`
317
+ update reports
318
+ set status = ${input.status},
319
+ resolved_by_user_id = ${input.actorUserId},
320
+ resolved_at = ${input.at},
321
+ updated_at = ${input.at}
322
+ where id = ${input.reportId} and status = 'open'
323
+ returning id, forum_id
324
+ `),
325
+ ) as Array<{ id: number; forum_id: number | null }>
326
+ const row = moved[0]
327
+ if (!row) return false
328
+
329
+ await tx.execute(sql`
330
+ insert into report_events (report_id, actor_user_id, kind, note, created_at)
331
+ values (${input.reportId}, ${input.actorUserId}, ${input.status},
332
+ ${input.note}, ${input.at})
333
+ `)
334
+
335
+ const forumId = row.forum_id === null ? null : Number(row.forum_id)
336
+ await logModeratorAction(
337
+ tx,
338
+ input.status === 'resolved' ? 'report.resolve' : 'report.reject',
339
+ input.actorUserId,
340
+ {
341
+ reportId: input.reportId,
342
+ ...(forumId === null ? {} : { forumId, forumIds: [forumId] }),
343
+ },
344
+ input.at,
345
+ )
346
+ return true
347
+ })
348
+ }
349
+ }
@@ -0,0 +1,295 @@
1
+ import type { SQLWrapper } from 'drizzle-orm'
2
+ import { sql } from 'drizzle-orm'
3
+
4
+ import type {
5
+ ReputationRepository,
6
+ ReputationRow,
7
+ ReputationSummary,
8
+ ReputationWriteResult,
9
+ } from '@meith/reputation'
10
+
11
+ import type { Database } from './client'
12
+ import { resultRows } from './result-rows'
13
+ import { toDate } from './row-values'
14
+
15
+ interface RawRow {
16
+ id: number
17
+ user_id: number
18
+ given_by_user_id: number | null
19
+ given_by_username: string | null
20
+ post_id: number | null
21
+ thread_id: number | null
22
+ points: number
23
+ comment: string
24
+ created_at: string | Date
25
+ updated_at: string | Date
26
+ }
27
+
28
+ function toRow(row: RawRow): ReputationRow {
29
+ return {
30
+ id: Number(row.id),
31
+ userId: Number(row.user_id),
32
+ givenByUserId: row.given_by_user_id === null ? null : Number(row.given_by_user_id),
33
+ givenByUsername: row.given_by_username,
34
+ postId: row.post_id === null ? null : Number(row.post_id),
35
+ threadId: row.thread_id === null ? null : Number(row.thread_id),
36
+ points: Number(row.points),
37
+ comment: row.comment,
38
+ createdAt: toDate(row.created_at),
39
+ updatedAt: toDate(row.updated_at),
40
+ }
41
+ }
42
+
43
+ const SELECT_ROW = sql`
44
+ select r.id, r.user_id, r.given_by_user_id, r.post_id, r.points, r.comment,
45
+ r.created_at, r.updated_at, u.username as given_by_username,
46
+ p.thread_id
47
+ from reputation r
48
+ left join users u on u.id = r.given_by_user_id
49
+ /* For the history's link. Left, because a profile rating has no post and a
50
+ rated post may since have been hard-deleted. */
51
+ left join posts p on p.id = r.post_id
52
+ `
53
+
54
+ interface ReputationTransaction {
55
+ execute(query: SQLWrapper): Promise<unknown>
56
+ }
57
+
58
+ export async function recountReputation(
59
+ tx: ReputationTransaction,
60
+ userIds: readonly number[],
61
+ ): Promise<void> {
62
+ if (userIds.length === 0) return
63
+
64
+ await tx.execute(sql`
65
+ update users u
66
+ set reputation = coalesce(
67
+ (select sum(r.points)::int from reputation r where r.user_id = u.id), 0)
68
+ where u.id in ${sql`(${sql.join(
69
+ userIds.map((id) => sql`${id}`),
70
+ sql`, `,
71
+ )})`}
72
+ `)
73
+ }
74
+
75
+ export class PostgresReputationRepository implements ReputationRepository {
76
+ constructor(private readonly db: Database) {}
77
+
78
+ async give(input: {
79
+ readonly userId: number
80
+ readonly givenByUserId: number
81
+ readonly postId: number | null
82
+ readonly points: number
83
+ readonly comment: string
84
+ readonly maxPerDay: number
85
+ readonly at: Date
86
+ }): Promise<ReputationWriteResult> {
87
+ return this.db.transaction(async (tx) => {
88
+ if (input.maxPerDay > 0) {
89
+ const startOfDay = new Date(
90
+ Date.UTC(input.at.getUTCFullYear(), input.at.getUTCMonth(), input.at.getUTCDate()),
91
+ )
92
+
93
+ const counted = resultRows(
94
+ await tx.execute(sql`
95
+ select count(*)::int as n
96
+ from reputation
97
+ where given_by_user_id = ${input.givenByUserId}
98
+ and created_at >= ${startOfDay}
99
+ `),
100
+ ) as Array<{ n: number }>
101
+
102
+ const already = Number(counted[0]?.n ?? 0)
103
+
104
+ const existing = resultRows(
105
+ await tx.execute(sql`
106
+ select id from reputation
107
+ where given_by_user_id = ${input.givenByUserId}
108
+ and user_id = ${input.userId}
109
+ and post_id is not distinct from ${input.postId}
110
+ `),
111
+ ) as Array<{ id: number }>
112
+
113
+ if (existing.length === 0 && already >= input.maxPerDay) return 'daily-limit'
114
+ }
115
+
116
+ if (input.postId === null) {
117
+ await tx.execute(sql`
118
+ insert into reputation
119
+ (user_id, given_by_user_id, post_id, points, comment, created_at, updated_at)
120
+ values (${input.userId}, ${input.givenByUserId}, null, ${input.points},
121
+ ${input.comment}, ${input.at}, ${input.at})
122
+ on conflict (given_by_user_id, user_id) where post_id is null
123
+ do update set points = excluded.points,
124
+ comment = excluded.comment,
125
+ updated_at = excluded.updated_at
126
+ `)
127
+ } else {
128
+ const written = resultRows(
129
+ await tx.execute(sql`
130
+ insert into reputation
131
+ (user_id, given_by_user_id, post_id, points, comment, created_at, updated_at)
132
+ select ${input.userId}, ${input.givenByUserId}, p.id, ${input.points},
133
+ ${input.comment}, ${input.at}, ${input.at}
134
+ from posts p
135
+ where p.id = ${input.postId}
136
+ and p.author_user_id = ${input.userId}
137
+ on conflict (given_by_user_id, post_id) where post_id is not null
138
+ do update set points = excluded.points,
139
+ comment = excluded.comment,
140
+ updated_at = excluded.updated_at
141
+ returning id
142
+ `),
143
+ )
144
+ if (written.length === 0) return 'invalid-post'
145
+ }
146
+
147
+ await recountReputation(tx, [input.userId])
148
+ return 'written'
149
+ })
150
+ }
151
+
152
+ async withdraw(input: {
153
+ readonly ratingId: number
154
+ readonly givenByUserId: number
155
+ }): Promise<boolean> {
156
+ return this.db.transaction(async (tx) => {
157
+ const rows = resultRows(
158
+ await tx.execute(sql`
159
+ delete from reputation
160
+ where id = ${input.ratingId} and given_by_user_id = ${input.givenByUserId}
161
+ returning user_id
162
+ `),
163
+ ) as Array<{ user_id: number }>
164
+
165
+ const row = rows[0]
166
+ if (row === undefined) return false
167
+
168
+ await recountReputation(tx, [Number(row.user_id)])
169
+ return true
170
+ })
171
+ }
172
+
173
+ async list(input: {
174
+ readonly userId: number
175
+ readonly limit: number
176
+ readonly before?: number | undefined
177
+ }): Promise<readonly ReputationRow[]> {
178
+ const before = input.before === undefined ? sql`` : sql`and r.id < ${input.before}`
179
+
180
+ const rows = resultRows(
181
+ await this.db.execute(sql`
182
+ ${SELECT_ROW}
183
+ where r.user_id = ${input.userId} ${before}
184
+ order by r.id desc
185
+ limit ${input.limit}
186
+ `),
187
+ ) as RawRow[]
188
+
189
+ return rows.map(toRow)
190
+ }
191
+
192
+ async summary(userId: number): Promise<ReputationSummary> {
193
+ const rows = resultRows(
194
+ await this.db.execute(sql`
195
+ select coalesce(sum(points), 0)::int as total,
196
+ count(*) filter (where points > 0)::int as positive,
197
+ count(*) filter (where points = 0)::int as neutral,
198
+ count(*) filter (where points < 0)::int as negative
199
+ from reputation
200
+ where user_id = ${userId}
201
+ `),
202
+ ) as Array<{ total: number; positive: number; neutral: number; negative: number }>
203
+
204
+ const row = rows[0]
205
+ return {
206
+ total: Number(row?.total ?? 0),
207
+ positive: Number(row?.positive ?? 0),
208
+ neutral: Number(row?.neutral ?? 0),
209
+ negative: Number(row?.negative ?? 0),
210
+ }
211
+ }
212
+
213
+ async existing(input: {
214
+ readonly givenByUserId: number
215
+ readonly userId: number
216
+ readonly postId: number | null
217
+ }): Promise<ReputationRow | null> {
218
+ const rows = resultRows(
219
+ await this.db.execute(sql`
220
+ ${SELECT_ROW}
221
+ where r.given_by_user_id = ${input.givenByUserId}
222
+ and r.user_id = ${input.userId}
223
+ /* "is not distinct from" so a null post id matches a null one:
224
+ plain equality is never true against NULL, which would make every
225
+ profile rating look like a new one. */
226
+ and r.post_id is not distinct from ${input.postId}
227
+ `),
228
+ ) as RawRow[]
229
+
230
+ return rows[0] === undefined ? null : toRow(rows[0])
231
+ }
232
+
233
+ async existingForPosts(input: {
234
+ readonly givenByUserId: number
235
+ readonly postIds: readonly number[]
236
+ }): Promise<ReadonlyMap<number, ReputationRow>> {
237
+ if (input.postIds.length === 0) return new Map()
238
+
239
+ const rows = resultRows(
240
+ await this.db.execute(sql`
241
+ ${SELECT_ROW}
242
+ where r.given_by_user_id = ${input.givenByUserId}
243
+ and r.post_id in ${sql`(${sql.join(
244
+ input.postIds.map((id) => sql`${id}`),
245
+ sql`, `,
246
+ )})`}
247
+ `),
248
+ ) as RawRow[]
249
+
250
+ return new Map(
251
+ rows.map(toRow).flatMap((row) => (row.postId === null ? [] : [[row.postId, row] as const])),
252
+ )
253
+ }
254
+
255
+ async thanksForPosts(postIds: readonly number[]): Promise<ReadonlyMap<number, number>> {
256
+ if (postIds.length === 0) return new Map()
257
+
258
+ const rows = resultRows(
259
+ await this.db.execute(sql`
260
+ select post_id, count(*)::int as n
261
+ from reputation
262
+ where points > 0
263
+ and post_id in ${sql`(${sql.join(
264
+ postIds.map((id) => sql`${id}`),
265
+ sql`, `,
266
+ )})`}
267
+ group by post_id
268
+ `),
269
+ ) as Array<{ post_id: number; n: number }>
270
+
271
+ return new Map(rows.map((row) => [Number(row.post_id), Number(row.n)]))
272
+ }
273
+
274
+ async givenSince(givenByUserId: number, since: Date): Promise<number> {
275
+ const rows = resultRows(
276
+ await this.db.execute(sql`
277
+ select count(*)::int as n
278
+ from reputation
279
+ where given_by_user_id = ${givenByUserId} and created_at >= ${since}
280
+ `),
281
+ ) as Array<{ n: number }>
282
+
283
+ return Number(rows[0]?.n ?? 0)
284
+ }
285
+
286
+ async recount(userId: number): Promise<number> {
287
+ await recountReputation(this.db, [userId])
288
+
289
+ const rows = resultRows(
290
+ await this.db.execute(sql`select reputation from users where id = ${userId}`),
291
+ ) as Array<{ reputation: number }>
292
+
293
+ return Number(rows[0]?.reputation ?? 0)
294
+ }
295
+ }
@@ -0,0 +1,13 @@
1
+ export function resultRows<T = Record<string, unknown>>(result: unknown): T[] {
2
+ if (Array.isArray(result)) return result as T[]
3
+
4
+ if (
5
+ typeof result === 'object' &&
6
+ result !== null &&
7
+ Array.isArray((result as { rows?: unknown }).rows)
8
+ ) {
9
+ return (result as { rows: T[] }).rows
10
+ }
11
+
12
+ return []
13
+ }
@@ -0,0 +1,7 @@
1
+ export function toDate(value: unknown): Date {
2
+ return value instanceof Date ? value : new Date(String(value))
3
+ }
4
+
5
+ export function toNullableDate(value: unknown): Date | null {
6
+ return value === null || value === undefined ? null : toDate(value)
7
+ }