@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.
- package/LICENSE.md +165 -0
- package/migrations/0000_initial_schema.sql +538 -0
- package/migrations/0001_seed_usergroups.sql +111 -0
- package/migrations/0002_group_promotions.sql +25 -0
- package/migrations/0003_counter_maintenance.sql +34 -0
- package/migrations/0004_post_render_cache.sql +7 -0
- package/migrations/0005_reports.sql +51 -0
- package/migrations/0006_warnings.sql +91 -0
- package/migrations/0007_notifications.sql +44 -0
- package/migrations/0008_subscriptions.sql +48 -0
- package/migrations/0009_member_preferences.sql +13 -0
- package/migrations/0010_profile_fields.sql +53 -0
- package/migrations/0011_private_messages.sql +63 -0
- package/migrations/0012_user_relations.sql +18 -0
- package/migrations/0013_reputation.sql +45 -0
- package/migrations/0014_signatures.sql +16 -0
- package/migrations/0015_admin_sessions.sql +24 -0
- package/migrations/0016_attachments.sql +51 -0
- package/migrations/0017_avatars.sql +33 -0
- package/migrations/0018_mass_mail.sql +15 -0
- package/migrations/0019_word_filters.sql +8 -0
- package/migrations/0020_searches.sql +15 -0
- package/migrations/0021_presence_stats.sql +18 -0
- package/migrations/0022_api_tokens_webhooks.sql +58 -0
- package/migrations/0023_install_state.sql +6 -0
- package/migrations/0024_component_versions.sql +13 -0
- package/migrations/0025_import.sql +25 -0
- package/migrations/0026_polls.sql +40 -0
- package/migrations/0027_drafts.sql +13 -0
- package/migrations/0028_board_vocabulary.sql +32 -0
- package/migrations/0029_announcements.sql +16 -0
- package/migrations/0030_antispam.sql +18 -0
- package/migrations/0031_markdown.sql +24 -0
- package/migrations/0032_theme_enablement.sql +1 -0
- package/migrations/0033_group_identity.sql +5 -0
- package/migrations/0034_search_document_version.sql +4 -0
- package/migrations/0035_categories_hold_no_threads.sql +1 -0
- package/migrations/0036_expiring_group_grants.sql +6 -0
- package/migrations/0037_automatic_timezone.sql +3 -0
- package/migrations/0038_primary_group_grants.sql +3 -0
- package/migrations/0039_moderator_log_forum_scope.sql +1 -0
- package/migrations/0040_moderator_restore_grants.sql +1 -0
- package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
- package/migrations/0042_drop_placebo_delete_others.sql +2 -0
- package/migrations/0043_federated_identity.sql +28 -0
- package/migrations/0044_second_factor.sql +34 -0
- package/migrations/0045_drop_dead_task_due_index.sql +1 -0
- package/migrations/0046_member_locale.sql +1 -0
- package/migrations/0047_navigation_items.sql +34 -0
- package/migrations/0048_web_push.sql +16 -0
- package/migrations/0049_plugin_health.sql +7 -0
- package/migrations/0050_drop_forum_password_grants.sql +1 -0
- package/migrations/0051_glossy_barracuda.sql +1 -0
- package/migrations/0052_many_professor_monster.sql +2 -0
- package/migrations/0053_strange_maximus.sql +14 -0
- package/migrations/0054_legacy_import_sources.sql +25 -0
- package/migrations/0055_poll_parity.sql +14 -0
- package/migrations/0056_marketplace_catalog.sql +11 -0
- package/migrations/meta/0000_snapshot.json +4412 -0
- package/migrations/meta/0006_snapshot.json +5411 -0
- package/migrations/meta/0007_snapshot.json +5634 -0
- package/migrations/meta/0008_snapshot.json +5779 -0
- package/migrations/meta/0009_snapshot.json +5816 -0
- package/migrations/meta/0010_snapshot.json +6117 -0
- package/migrations/meta/0011_snapshot.json +6406 -0
- package/migrations/meta/0012_snapshot.json +6517 -0
- package/migrations/meta/0013_snapshot.json +6722 -0
- package/migrations/meta/0014_snapshot.json +6777 -0
- package/migrations/meta/0015_snapshot.json +6914 -0
- package/migrations/meta/0016_snapshot.json +7192 -0
- package/migrations/meta/0017_snapshot.json +7248 -0
- package/migrations/meta/0018_snapshot.json +7355 -0
- package/migrations/meta/0019_snapshot.json +7419 -0
- package/migrations/meta/0020_snapshot.json +7562 -0
- package/migrations/meta/0021_snapshot.json +7653 -0
- package/migrations/meta/0031_snapshot.json +8162 -0
- package/migrations/meta/0032_snapshot.json +8169 -0
- package/migrations/meta/0033_snapshot.json +8193 -0
- package/migrations/meta/0034_snapshot.json +8221 -0
- package/migrations/meta/0036_snapshot.json +8262 -0
- package/migrations/meta/0037_snapshot.json +8262 -0
- package/migrations/meta/0038_snapshot.json +8293 -0
- package/migrations/meta/0039_snapshot.json +8308 -0
- package/migrations/meta/0041_snapshot.json +8287 -0
- package/migrations/meta/0042_snapshot.json +8274 -0
- package/migrations/meta/0043_snapshot.json +8521 -0
- package/migrations/meta/0044_snapshot.json +8814 -0
- package/migrations/meta/0045_snapshot.json +8797 -0
- package/migrations/meta/0046_snapshot.json +8804 -0
- package/migrations/meta/0047_snapshot.json +9018 -0
- package/migrations/meta/0048_snapshot.json +9145 -0
- package/migrations/meta/0049_snapshot.json +9190 -0
- package/migrations/meta/0050_snapshot.json +9126 -0
- package/migrations/meta/0051_snapshot.json +9132 -0
- package/migrations/meta/0052_snapshot.json +9148 -0
- package/migrations/meta/0053_snapshot.json +9268 -0
- package/migrations/meta/0054_snapshot.json +9378 -0
- package/migrations/meta/0056_snapshot.json +9447 -0
- package/migrations/meta/_journal.json +405 -0
- package/package.json +64 -0
- package/src/account-repos.ts +580 -0
- package/src/actor-builder.ts +130 -0
- package/src/admin-repo.ts +85 -0
- package/src/admin-session-repo.ts +205 -0
- package/src/admin-undo-repo.ts +128 -0
- package/src/announcement-repo.ts +176 -0
- package/src/antispam-repo.ts +145 -0
- package/src/api-repo.ts +276 -0
- package/src/attachment-admin-repo.ts +161 -0
- package/src/attachment-repo.ts +240 -0
- package/src/authorization-source.ts +138 -0
- package/src/avatar-repo.ts +185 -0
- package/src/ban-repos.ts +146 -0
- package/src/client.ts +85 -0
- package/src/content-admin-repo.ts +298 -0
- package/src/content-counters.ts +152 -0
- package/src/counter-recount.ts +263 -0
- package/src/cursor.ts +16 -0
- package/src/denormalised-username.ts +34 -0
- package/src/discovery-repo.ts +140 -0
- package/src/draft-repo.ts +59 -0
- package/src/feed-repo.ts +219 -0
- package/src/forum-admin-repo.ts +310 -0
- package/src/forum-repo.ts +206 -0
- package/src/group-admin-repo.ts +256 -0
- package/src/group-identity-repo.ts +84 -0
- package/src/identity-link-repo.ts +145 -0
- package/src/import-repo.ts +123 -0
- package/src/import-sink.ts +1377 -0
- package/src/index.ts +316 -0
- package/src/inline-moderation.ts +344 -0
- package/src/install-repo.ts +67 -0
- package/src/latest-repo.ts +115 -0
- package/src/maintenance-repo.ts +54 -0
- package/src/marketplace-repo.ts +111 -0
- package/src/member-profile-repo.ts +82 -0
- package/src/member-settings-repo.ts +160 -0
- package/src/message-repo.ts +384 -0
- package/src/migrate-run.ts +18 -0
- package/src/migrate.ts +129 -0
- package/src/modcp-repo.ts +253 -0
- package/src/moderation-queue.ts +279 -0
- package/src/navigation-repo.ts +288 -0
- package/src/notification-repo.ts +402 -0
- package/src/orphan-keys.ts +19 -0
- package/src/outbox-repo.ts +78 -0
- package/src/permission-version.ts +24 -0
- package/src/permissions-map.ts +52 -0
- package/src/pglite.fixture.ts +87 -0
- package/src/plugin-data.ts +88 -0
- package/src/plugin-grants.ts +369 -0
- package/src/plugin-health-repo.ts +80 -0
- package/src/plugin-purge-repo.ts +91 -0
- package/src/plugin-users.ts +39 -0
- package/src/poll-repo.ts +334 -0
- package/src/post-repo.ts +261 -0
- package/src/post-writes.ts +227 -0
- package/src/presence-repo.ts +220 -0
- package/src/profile-field-repo.ts +182 -0
- package/src/promotion-repo.ts +152 -0
- package/src/read-state-repo.ts +81 -0
- package/src/relation-repo.ts +113 -0
- package/src/render-backfill.ts +110 -0
- package/src/render-signature.ts +33 -0
- package/src/report-repo.ts +349 -0
- package/src/reputation-repo.ts +295 -0
- package/src/result-rows.ts +13 -0
- package/src/row-values.ts +7 -0
- package/src/schema/content.ts +451 -0
- package/src/schema/identity.ts +699 -0
- package/src/schema/index.ts +10 -0
- package/src/schema/messages.ts +76 -0
- package/src/schema/permission-columns.ts +34 -0
- package/src/schema/platform.ts +376 -0
- package/src/schema/structure.ts +164 -0
- package/src/search-repo.ts +359 -0
- package/src/search-store.ts +113 -0
- package/src/seed-groups.ts +11 -0
- package/src/settings-repo.ts +32 -0
- package/src/signature-repo.ts +102 -0
- package/src/sql-lists.ts +17 -0
- package/src/staff-groups.ts +50 -0
- package/src/stats-repo.ts +151 -0
- package/src/subscription-repo.ts +350 -0
- package/src/system-health-repo.ts +130 -0
- package/src/task-repo.ts +104 -0
- package/src/theme-admin-repo.ts +172 -0
- package/src/theme-repo.ts +43 -0
- package/src/thread-audience.ts +45 -0
- package/src/thread-counters.ts +107 -0
- package/src/thread-repo.ts +255 -0
- package/src/thread-surgery.ts +282 -0
- package/src/thread-tools.ts +311 -0
- package/src/thread-views.ts +41 -0
- package/src/thread-writes.ts +356 -0
- package/src/two-factor-repo.ts +235 -0
- package/src/upgrade-repo.ts +66 -0
- package/src/user-admin-repo.ts +367 -0
- package/src/user-bulk-repo.ts +397 -0
- package/src/user-merge-map.ts +111 -0
- package/src/user-merge-repo.ts +304 -0
- package/src/visibility-counters.ts +190 -0
- package/src/visibility.ts +17 -0
- package/src/vocabulary-repo.ts +54 -0
- 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
|
+
}
|