@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,397 @@
1
+ import { type SQL, sql } from 'drizzle-orm'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import { msg } from '@meith/i18n'
5
+
6
+ import type { Database } from './client'
7
+ import { withPermissionVersionBump } from './permission-version'
8
+ import { resultRows } from './result-rows'
9
+ import { isStaffGroupSql } from './staff-groups'
10
+ import { BANNED_PREDICATE } from './user-admin-repo'
11
+ import { ACCOUNT_CLOSURE_DISCARD } from './user-merge-map'
12
+
13
+ export interface PruneCriteria {
14
+ readonly registeredBefore: Date
15
+ readonly inactiveSince?: Date | undefined
16
+ readonly onlyAwaitingActivation?: boolean | undefined
17
+ }
18
+
19
+ export interface PruneCandidate {
20
+ readonly id: number
21
+ readonly username: string
22
+ readonly email: string
23
+ readonly createdAt: Date
24
+ }
25
+
26
+ export interface PrunePreview {
27
+ readonly total: number
28
+ readonly sample: readonly PruneCandidate[]
29
+ }
30
+
31
+ export interface PruneChunkResult {
32
+ readonly pruned: number
33
+ readonly prunedUserIds: readonly number[]
34
+ readonly remaining: number
35
+ }
36
+
37
+ export interface MassMailRow {
38
+ readonly id: number
39
+ readonly subject: string
40
+ readonly body: string
41
+ readonly targetGroupId: number | null
42
+ readonly lastUserId: number
43
+ readonly queuedCount: number
44
+ readonly status: string
45
+ }
46
+
47
+ export interface MassMailRecipient {
48
+ readonly userId: number
49
+ readonly email: string
50
+ readonly username: string
51
+ }
52
+
53
+ export interface MassMailChunk {
54
+ readonly recipients: readonly MassMailRecipient[]
55
+ readonly finished: boolean
56
+ }
57
+
58
+ export class PostgresUserBulkRepository {
59
+ constructor(private readonly db: Database) {}
60
+
61
+ private pruneWhere(criteria: PruneCriteria): SQL {
62
+ const conditions: SQL[] = [
63
+ sql`u.deleted_at is null`,
64
+ sql`u.created_at < ${criteria.registeredBefore}`,
65
+ sql`u.post_count = 0`,
66
+ sql`u.thread_count = 0`,
67
+ sql`not exists (select 1 from posts p where p.author_user_id = u.id)`,
68
+ sql`not exists (select 1 from threads t where t.author_user_id = u.id)`,
69
+ sql`not ${BANNED_PREDICATE}`,
70
+ sql`not exists (
71
+ select 1 from usergroups g
72
+ where g.id = u.primary_group_id and (${isStaffGroupSql('g')})
73
+ )`,
74
+ sql`not exists (
75
+ select 1 from user_group_memberships m
76
+ join usergroups g on g.id = m.group_id
77
+ where m.user_id = u.id and (${isStaffGroupSql('g')})
78
+ )`,
79
+ sql`not exists (select 1 from forum_moderators f where f.user_id = u.id)`,
80
+ ]
81
+
82
+ if (criteria.inactiveSince !== undefined) {
83
+ conditions.push(
84
+ sql`(u.last_active_at is null or u.last_active_at < ${criteria.inactiveSince})`,
85
+ )
86
+ }
87
+ if (criteria.onlyAwaitingActivation === true) {
88
+ conditions.push(sql`u.state = 'awaiting_activation'`)
89
+ }
90
+
91
+ return sql.join(conditions, sql` and `)
92
+ }
93
+
94
+ async prunePreview(criteria: PruneCriteria, sampleSize = 10): Promise<PrunePreview> {
95
+ const where = this.pruneWhere(criteria)
96
+
97
+ const totals = resultRows(
98
+ await this.db.execute(sql`select count(*)::int as n from users u where ${where}`),
99
+ ) as Array<{ n: number }>
100
+
101
+ const sample = resultRows(
102
+ await this.db.execute(sql`
103
+ select u.id, u.username, u.email, u.created_at
104
+ from users u
105
+ where ${where}
106
+ order by u.id
107
+ limit ${sampleSize}
108
+ `),
109
+ ) as Array<Record<string, unknown>>
110
+
111
+ return {
112
+ total: Number(totals[0]?.n ?? 0),
113
+ sample: sample.map((row) => ({
114
+ id: Number(row.id),
115
+ username: String(row.username),
116
+ email: String(row.email),
117
+ createdAt:
118
+ row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at)),
119
+ })),
120
+ }
121
+ }
122
+
123
+ async pruneChunk(criteria: PruneCriteria, limit: number): Promise<PruneChunkResult> {
124
+ const where = this.pruneWhere(criteria)
125
+
126
+ const pruned = await withPermissionVersionBump(this.db, async (tx) => {
127
+ const candidates = resultRows(
128
+ await tx.execute(sql`
129
+ select u.id
130
+ from users u
131
+ where ${where}
132
+ order by u.id
133
+ limit ${limit}
134
+ for update
135
+ `),
136
+ ) as Array<{ id: number }>
137
+ const userIds = candidates.map((row) => Number(row.id))
138
+ if (userIds.length === 0) return []
139
+
140
+ const idList = sql.join(
141
+ userIds.map((id) => sql`${id}`),
142
+ sql`, `,
143
+ )
144
+ for (const entry of ACCOUNT_CLOSURE_DISCARD) {
145
+ await tx.execute(sql`
146
+ delete from ${sql.raw(entry.table)}
147
+ where ${sql.raw(entry.column)} in (${idList})
148
+ `)
149
+ }
150
+
151
+ await tx.execute(sql`
152
+ update remember_tokens
153
+ set revoked_at = now(), revoked_reason = 'account_closure'
154
+ where user_id in (${idList}) and revoked_at is null
155
+ `)
156
+
157
+ await tx.execute(sql`
158
+ update sessions
159
+ set revoked_at = now()
160
+ where user_id in (${idList}) and revoked_at is null
161
+ `)
162
+
163
+ const rows = resultRows(
164
+ await tx.execute(sql`
165
+ update users
166
+ set deleted_at = now(), updated_at = now()
167
+ where id in (${idList})
168
+ returning id
169
+ `),
170
+ ) as Array<{ id: number }>
171
+ return rows.map((row) => Number(row.id))
172
+ })
173
+
174
+ const remaining = resultRows(
175
+ await this.db.execute(sql`select count(*)::int as n from users u where ${where}`),
176
+ ) as Array<{ n: number }>
177
+
178
+ return {
179
+ pruned: pruned.length,
180
+ prunedUserIds: pruned,
181
+ remaining: Number(remaining[0]?.n ?? 0),
182
+ }
183
+ }
184
+
185
+ async selectedPrunePreview(userIds: readonly number[]): Promise<PrunePreview> {
186
+ if (userIds.length === 0) return { total: 0, sample: [] }
187
+ const idList = sql.join(
188
+ userIds.map((id) => sql`${id}`),
189
+ sql`, `,
190
+ )
191
+ const rows = resultRows(
192
+ await this.db.execute(sql`
193
+ select u.id, u.username, u.email, u.created_at
194
+ from users u
195
+ where u.id in (${idList})
196
+ and u.deleted_at is null
197
+ and u.post_count = 0
198
+ and u.thread_count = 0
199
+ and not exists (select 1 from posts p where p.author_user_id = u.id)
200
+ and not exists (select 1 from threads t where t.author_user_id = u.id)
201
+ and not ${BANNED_PREDICATE}
202
+ and not exists (
203
+ select 1 from usergroups g
204
+ where g.id = u.primary_group_id and (${isStaffGroupSql('g')})
205
+ )
206
+ and not exists (
207
+ select 1 from user_group_memberships m
208
+ join usergroups g on g.id = m.group_id
209
+ where m.user_id = u.id and (${isStaffGroupSql('g')})
210
+ )
211
+ and not exists (select 1 from forum_moderators f where f.user_id = u.id)
212
+ order by u.id
213
+ `),
214
+ ) as Array<Record<string, unknown>>
215
+
216
+ const sample = rows.map((row) => ({
217
+ id: Number(row.id),
218
+ username: String(row.username),
219
+ email: String(row.email),
220
+ createdAt: row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at)),
221
+ }))
222
+ return { total: sample.length, sample }
223
+ }
224
+
225
+ async pruneSelected(userIds: readonly number[]): Promise<readonly number[]> {
226
+ const eligible = await this.selectedPrunePreview(userIds)
227
+ const ids = eligible.sample.map((row) => row.id)
228
+ if (ids.length === 0) return []
229
+ const idList = sql.join(
230
+ ids.map((id) => sql`${id}`),
231
+ sql`, `,
232
+ )
233
+
234
+ return withPermissionVersionBump(this.db, async (tx) => {
235
+ for (const entry of ACCOUNT_CLOSURE_DISCARD) {
236
+ await tx.execute(sql`
237
+ delete from ${sql.raw(entry.table)} where ${sql.raw(entry.column)} in (${idList})
238
+ `)
239
+ }
240
+ await tx.execute(sql`
241
+ update remember_tokens set revoked_at = now(), revoked_reason = 'account_closure'
242
+ where user_id in (${idList}) and revoked_at is null
243
+ `)
244
+ await tx.execute(sql`
245
+ update sessions set revoked_at = now()
246
+ where user_id in (${idList}) and revoked_at is null
247
+ `)
248
+ const rows = resultRows(
249
+ await tx.execute(sql`
250
+ update users set deleted_at = now(), updated_at = now()
251
+ where id in (${idList}) and deleted_at is null returning id
252
+ `),
253
+ ) as Array<{ id: number }>
254
+ return rows.map((row) => Number(row.id))
255
+ })
256
+ }
257
+
258
+ async createMassMail(input: {
259
+ readonly subject: string
260
+ readonly body: string
261
+ readonly targetGroupId: number | null
262
+ readonly createdByUserId: number | null
263
+ }): Promise<number> {
264
+ if (input.subject.trim() === '')
265
+ throw new ValidationError(msg('error.db.message-needs-subject'))
266
+ if (input.body.trim() === '') throw new ValidationError(msg('error.db.message-needs-body'))
267
+
268
+ const rows = resultRows(
269
+ await this.db.execute(sql`
270
+ insert into mass_mails (subject, body, target_group_id, created_by_user_id)
271
+ values (${input.subject.trim()}, ${input.body.trim()},
272
+ ${input.targetGroupId}, ${input.createdByUserId})
273
+ returning id
274
+ `),
275
+ ) as Array<{ id: number }>
276
+
277
+ return Number(rows[0]?.id)
278
+ }
279
+
280
+ async readMassMail(id: number): Promise<MassMailRow | null> {
281
+ const rows = resultRows(
282
+ await this.db.execute(sql`select * from mass_mails where id = ${id}`),
283
+ ) as Array<Record<string, unknown>>
284
+
285
+ const row = rows[0]
286
+ if (row === undefined) return null
287
+
288
+ return {
289
+ id: Number(row.id),
290
+ subject: String(row.subject),
291
+ body: String(row.body),
292
+ targetGroupId: row.target_group_id === null ? null : Number(row.target_group_id),
293
+ lastUserId: Number(row.last_user_id),
294
+ queuedCount: Number(row.queued_count),
295
+ status: String(row.status),
296
+ }
297
+ }
298
+
299
+ async massMailAudience(targetGroupId: number | null): Promise<number> {
300
+ const rows = resultRows(
301
+ await this.db.execute(sql`
302
+ select count(*)::int as n from users u
303
+ where ${this.audienceWhere(targetGroupId)}
304
+ `),
305
+ ) as Array<{ n: number }>
306
+ return Number(rows[0]?.n ?? 0)
307
+ }
308
+
309
+ async massMailAudienceByGroup(): Promise<ReadonlyMap<number, number>> {
310
+ const rows = resultRows(
311
+ await this.db.execute(sql`
312
+ select g.id as group_id, count(u.id)::int as n
313
+ from usergroups g
314
+ left join users u
315
+ on (
316
+ u.primary_group_id = g.id
317
+ or exists (
318
+ select 1 from user_group_memberships m
319
+ where m.user_id = u.id and m.group_id = g.id
320
+ )
321
+ )
322
+ and ${this.audienceWhere(null)}
323
+ group by g.id
324
+ `),
325
+ ) as Array<{ group_id: number; n: number }>
326
+
327
+ return new Map(rows.map((row) => [Number(row.group_id), Number(row.n)]))
328
+ }
329
+
330
+ private audienceWhere(targetGroupId: number | null): SQL {
331
+ const conditions: SQL[] = [
332
+ sql`u.deleted_at is null`,
333
+ sql`u.state = 'active'`,
334
+ sql`u.email_verified_at is not null`,
335
+ ]
336
+
337
+ if (targetGroupId !== null) {
338
+ conditions.push(sql`(
339
+ u.primary_group_id = ${targetGroupId}
340
+ or exists (
341
+ select 1 from user_group_memberships m
342
+ where m.user_id = u.id and m.group_id = ${targetGroupId}
343
+ )
344
+ )`)
345
+ }
346
+
347
+ return sql.join(conditions, sql` and `)
348
+ }
349
+
350
+ async claimMassMailChunk(massMailId: number, limit: number): Promise<MassMailChunk> {
351
+ return this.db.transaction(async (tx) => {
352
+ const mails = resultRows(
353
+ await tx.execute(sql`
354
+ select id, target_group_id, last_user_id, status
355
+ from mass_mails where id = ${massMailId} for update
356
+ `),
357
+ ) as Array<Record<string, unknown>>
358
+
359
+ const mail = mails[0]
360
+ if (mail === undefined) throw new ValidationError(msg('error.db.such-message'))
361
+ if (String(mail.status) !== 'sending') return { recipients: [], finished: true }
362
+
363
+ const targetGroupId = mail.target_group_id === null ? null : Number(mail.target_group_id)
364
+ const after = Number(mail.last_user_id)
365
+
366
+ const rows = resultRows(
367
+ await tx.execute(sql`
368
+ select u.id, u.username, u.email
369
+ from users u
370
+ where ${this.audienceWhere(targetGroupId)} and u.id > ${after}
371
+ order by u.id
372
+ limit ${limit}
373
+ `),
374
+ ) as Array<Record<string, unknown>>
375
+
376
+ const recipients = rows.map((row) => ({
377
+ userId: Number(row.id),
378
+ username: String(row.username),
379
+ email: String(row.email),
380
+ }))
381
+
382
+ const finished = recipients.length < limit
383
+ const cursor = recipients.at(-1)?.userId ?? after
384
+
385
+ await tx.execute(sql`
386
+ update mass_mails
387
+ set last_user_id = ${cursor},
388
+ queued_count = queued_count + ${recipients.length},
389
+ status = ${finished ? 'finished' : 'sending'},
390
+ finished_at = ${finished ? sql`now()` : sql`null`}
391
+ where id = ${massMailId}
392
+ `)
393
+
394
+ return { recipients, finished }
395
+ })
396
+ }
397
+ }
@@ -0,0 +1,111 @@
1
+ import { DENORMALISED_USERNAME_COLUMNS } from './denormalised-username'
2
+
3
+ export interface ReassignColumn {
4
+ readonly table: string
5
+ readonly column: string
6
+ }
7
+
8
+ export interface DedupeColumn extends ReassignColumn {
9
+ readonly keys: readonly string[]
10
+ readonly where?: string
11
+ }
12
+
13
+ export type DiscardColumn = ReassignColumn
14
+
15
+ export const MERGE_REASSIGN: readonly ReassignColumn[] = [
16
+ { table: 'admin_log', column: 'user_id' },
17
+ { table: 'admin_undo_operations', column: 'actor_user_id' },
18
+ { table: 'attachments', column: 'uploader_user_id' },
19
+ { table: 'ban_filters', column: 'created_by_user_id' },
20
+ { table: 'bans', column: 'banned_by_user_id' },
21
+ { table: 'bans', column: 'user_id' },
22
+ { table: 'forums', column: 'last_post_user_id' },
23
+ { table: 'threads', column: 'last_post_user_id' },
24
+ { table: 'post_revisions', column: 'edited_by_user_id' },
25
+ { table: 'posts', column: 'author_user_id' },
26
+ { table: 'posts', column: 'edited_by_user_id' },
27
+ { table: 'private_messages', column: 'author_user_id' },
28
+ { table: 'report_events', column: 'actor_user_id' },
29
+ { table: 'reports', column: 'assigned_to_user_id' },
30
+ { table: 'reports', column: 'resolved_by_user_id' },
31
+ { table: 'settings', column: 'updated_by_user_id' },
32
+ { table: 'threads', column: 'author_user_id' },
33
+ { table: 'mass_mails', column: 'created_by_user_id' },
34
+ { table: 'announcements', column: 'author_user_id' },
35
+ { table: 'board_stats', column: 'newest_user_id' },
36
+ { table: 'user_group_memberships', column: 'granted_by_user_id' },
37
+ { table: 'push_subscriptions', column: 'user_id' },
38
+ { table: 'auth_events', column: 'user_id' },
39
+ { table: 'warnings', column: 'issued_by_user_id' },
40
+ { table: 'warnings', column: 'revoked_by_user_id' },
41
+ { table: 'warnings', column: 'user_id' },
42
+ ]
43
+
44
+ export const MERGE_DEDUPE: readonly DedupeColumn[] = [
45
+ { table: 'digest_runs', column: 'user_id', keys: ['cadence'] },
46
+ { table: 'forum_moderators', column: 'user_id', keys: ['forum_id'] },
47
+ { table: 'forum_subscriptions', column: 'user_id', keys: ['forum_id'] },
48
+ { table: 'forums_read', column: 'user_id', keys: ['forum_id'] },
49
+ {
50
+ table: 'notifications',
51
+ column: 'user_id',
52
+ keys: ['dedupe_key'],
53
+ where: 'read_at is null and dedupe_key is not null',
54
+ },
55
+ { table: 'notification_preferences', column: 'user_id', keys: ['kind'] },
56
+ { table: 'poll_votes', column: 'user_id', keys: ['poll_id'] },
57
+ { table: 'thread_ratings', column: 'user_id', keys: ['thread_id'] },
58
+ { table: 'private_message_copies', column: 'owner_user_id', keys: ['message_id'] },
59
+ { table: 'profile_field_values', column: 'user_id', keys: ['field_id'] },
60
+ {
61
+ table: 'reports',
62
+ column: 'reporter_user_id',
63
+ keys: ['target_kind', 'target_id'],
64
+ where: "status = 'open'",
65
+ },
66
+ { table: 'thread_subscriptions', column: 'user_id', keys: ['thread_id'] },
67
+ { table: 'threads_read', column: 'user_id', keys: ['thread_id'] },
68
+ { table: 'user_group_memberships', column: 'user_id', keys: ['group_id'] },
69
+ ]
70
+
71
+ export const ACCOUNT_CLOSURE_DISCARD: readonly DiscardColumn[] = [
72
+ { table: 'admin_sessions', column: 'user_id' },
73
+ { table: 'user_two_factor', column: 'user_id' },
74
+ { table: 'recovery_codes', column: 'user_id' },
75
+ { table: 'api_tokens', column: 'user_id' },
76
+ { table: 'credential_tokens', column: 'user_id' },
77
+ { table: 'remember_tokens', column: 'user_id' },
78
+ { table: 'sessions', column: 'user_id' },
79
+ { table: 'user_identities', column: 'user_id' },
80
+ { table: 'passkeys', column: 'user_id' },
81
+ ]
82
+
83
+ export const MERGE_DISCARD: readonly DiscardColumn[] = [
84
+ ...ACCOUNT_CLOSURE_DISCARD,
85
+ { table: 'searches', column: 'user_id' },
86
+ { table: 'post_drafts', column: 'user_id' },
87
+ ]
88
+
89
+ export const MERGE_BESPOKE: readonly ReassignColumn[] = [
90
+ { table: 'reputation', column: 'given_by_user_id' },
91
+ { table: 'reputation', column: 'user_id' },
92
+ { table: 'user_relations', column: 'other_user_id' },
93
+ { table: 'user_relations', column: 'user_id' },
94
+ ]
95
+
96
+ export const MERGE_NOT_A_REFERENCE: readonly ReassignColumn[] = [
97
+ { table: 'mass_mails', column: 'last_user_id' },
98
+ ]
99
+
100
+ export function mergeMapColumns(): readonly string[] {
101
+ return [
102
+ ...MERGE_REASSIGN,
103
+ ...MERGE_DEDUPE,
104
+ ...MERGE_DISCARD,
105
+ ...MERGE_BESPOKE,
106
+ ...DENORMALISED_USERNAME_COLUMNS,
107
+ ...MERGE_NOT_A_REFERENCE,
108
+ ]
109
+ .map((entry) => `${entry.table}.${entry.column}`)
110
+ .sort()
111
+ }