@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,304 @@
|
|
|
1
|
+
import { 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 { rewriteDenormalisedUsernames } from './denormalised-username'
|
|
8
|
+
import { type Tx, withPermissionVersionBump } from './permission-version'
|
|
9
|
+
import { recountReputation } from './reputation-repo'
|
|
10
|
+
import { resultRows } from './result-rows'
|
|
11
|
+
import { BANNED_PREDICATE } from './user-admin-repo'
|
|
12
|
+
import { type DedupeColumn, MERGE_DEDUPE, MERGE_DISCARD, MERGE_REASSIGN } from './user-merge-map'
|
|
13
|
+
import { recalculateWarningPoints } from './warning-repo'
|
|
14
|
+
|
|
15
|
+
export interface MergePreview {
|
|
16
|
+
readonly fromUsername: string
|
|
17
|
+
readonly toUsername: string
|
|
18
|
+
readonly posts: number
|
|
19
|
+
readonly threads: number
|
|
20
|
+
readonly privateMessages: number
|
|
21
|
+
readonly attachments: number
|
|
22
|
+
readonly blockedByBan: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MergeChunkResult {
|
|
26
|
+
readonly moved: number
|
|
27
|
+
readonly remaining: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class PostgresUserMergeRepository {
|
|
31
|
+
constructor(private readonly db: Database) {}
|
|
32
|
+
|
|
33
|
+
async preview(fromUserId: number, toUserId: number): Promise<MergePreview | null> {
|
|
34
|
+
const rows = resultRows(
|
|
35
|
+
await this.db.execute(sql`
|
|
36
|
+
select
|
|
37
|
+
(select username from users where id = ${fromUserId}) as from_username,
|
|
38
|
+
(select username from users where id = ${toUserId}) as to_username,
|
|
39
|
+
(select count(*) from posts where author_user_id = ${fromUserId})::int as posts,
|
|
40
|
+
(select count(*) from threads where author_user_id = ${fromUserId})::int as threads,
|
|
41
|
+
(select count(*) from private_messages where author_user_id = ${fromUserId})::int
|
|
42
|
+
as private_messages,
|
|
43
|
+
(select count(*) from attachments where uploader_user_id = ${fromUserId})::int
|
|
44
|
+
as attachments,
|
|
45
|
+
(select count(*) from users u
|
|
46
|
+
where u.id in (${fromUserId}, ${toUserId})
|
|
47
|
+
and ${BANNED_PREDICATE})::int as banned
|
|
48
|
+
`),
|
|
49
|
+
) as Array<Record<string, unknown>>
|
|
50
|
+
|
|
51
|
+
const row = rows[0]
|
|
52
|
+
if (row === undefined || row.from_username === null || row.to_username === null) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
fromUsername: String(row.from_username),
|
|
58
|
+
toUsername: String(row.to_username),
|
|
59
|
+
posts: Number(row.posts),
|
|
60
|
+
threads: Number(row.threads),
|
|
61
|
+
privateMessages: Number(row.private_messages),
|
|
62
|
+
attachments: Number(row.attachments),
|
|
63
|
+
blockedByBan: Number(row.banned) > 0,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async mergePostsChunk(
|
|
68
|
+
fromUserId: number,
|
|
69
|
+
toUserId: number,
|
|
70
|
+
limit: number,
|
|
71
|
+
): Promise<MergeChunkResult> {
|
|
72
|
+
assertDistinct(fromUserId, toUserId)
|
|
73
|
+
|
|
74
|
+
const moved = resultRows(
|
|
75
|
+
await this.db.execute(sql`
|
|
76
|
+
update posts
|
|
77
|
+
set author_user_id = ${toUserId}
|
|
78
|
+
where id in (
|
|
79
|
+
select id from posts where author_user_id = ${fromUserId} order by id limit ${limit}
|
|
80
|
+
)
|
|
81
|
+
returning id
|
|
82
|
+
`),
|
|
83
|
+
) as Array<{ id: number }>
|
|
84
|
+
|
|
85
|
+
const remaining = resultRows(
|
|
86
|
+
await this.db.execute(
|
|
87
|
+
sql`select count(*)::int as n from posts where author_user_id = ${fromUserId}`,
|
|
88
|
+
),
|
|
89
|
+
) as Array<{ n: number }>
|
|
90
|
+
|
|
91
|
+
return { moved: moved.length, remaining: Number(remaining[0]?.n ?? 0) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async finish(fromUserId: number, toUserId: number): Promise<void> {
|
|
95
|
+
assertDistinct(fromUserId, toUserId)
|
|
96
|
+
|
|
97
|
+
await withPermissionVersionBump(this.db, async (tx) => {
|
|
98
|
+
const state = resultRows(
|
|
99
|
+
await tx.execute(sql`
|
|
100
|
+
select
|
|
101
|
+
(select count(*) from users where id in (${fromUserId}, ${toUserId}))::int as found,
|
|
102
|
+
(select count(*) from users u
|
|
103
|
+
where u.id in (${fromUserId}, ${toUserId})
|
|
104
|
+
and ${BANNED_PREDICATE})::int as banned,
|
|
105
|
+
(select count(*) from posts where author_user_id = ${fromUserId})::int as posts
|
|
106
|
+
`),
|
|
107
|
+
) as Array<Record<string, unknown>>
|
|
108
|
+
|
|
109
|
+
const row = state[0] as Record<string, unknown>
|
|
110
|
+
if (Number(row.found) !== 2) throw new ValidationError(msg('error.db.such-member'))
|
|
111
|
+
if (Number(row.banned) > 0) {
|
|
112
|
+
throw new ValidationError(msg('error.db.one-these-accounts-banned-lift'))
|
|
113
|
+
}
|
|
114
|
+
if (Number(row.posts) > 0) {
|
|
115
|
+
throw new ValidationError(msg('error.db.there-still-posts-move-finish'))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const entry of MERGE_DISCARD) {
|
|
119
|
+
await tx.execute(sql`
|
|
120
|
+
delete from ${sql.raw(entry.table)}
|
|
121
|
+
where ${sql.raw(entry.column)} = ${fromUserId}
|
|
122
|
+
`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const entry of MERGE_DEDUPE) {
|
|
126
|
+
await dedupeThenMove(tx, entry, fromUserId, toUserId)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const rated = await mergeBespoke(tx, fromUserId, toUserId)
|
|
130
|
+
|
|
131
|
+
for (const entry of MERGE_REASSIGN) {
|
|
132
|
+
await tx.execute(sql`
|
|
133
|
+
update ${sql.raw(entry.table)}
|
|
134
|
+
set ${sql.raw(entry.column)} = ${toUserId}
|
|
135
|
+
where ${sql.raw(entry.column)} = ${fromUserId}
|
|
136
|
+
`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const winner = resultRows(
|
|
140
|
+
await tx.execute(sql`select username from users where id = ${toUserId}`),
|
|
141
|
+
) as Array<{ username: string }>
|
|
142
|
+
const username = winner[0]?.username
|
|
143
|
+
if (username === undefined) throw new ValidationError(msg('error.db.such-member'))
|
|
144
|
+
|
|
145
|
+
await rewriteDenormalisedUsernames(tx, toUserId, username)
|
|
146
|
+
|
|
147
|
+
await tx.execute(sql`
|
|
148
|
+
update users w
|
|
149
|
+
set post_count = w.post_count + l.post_count,
|
|
150
|
+
thread_count = w.thread_count + l.thread_count,
|
|
151
|
+
updated_at = now()
|
|
152
|
+
from users l
|
|
153
|
+
where w.id = ${toUserId} and l.id = ${fromUserId}
|
|
154
|
+
`)
|
|
155
|
+
|
|
156
|
+
const rerate = new Set(rated)
|
|
157
|
+
rerate.add(toUserId)
|
|
158
|
+
rerate.delete(fromUserId)
|
|
159
|
+
await recountReputation(tx, [...rerate])
|
|
160
|
+
|
|
161
|
+
await recalculateWarningPoints(tx, toUserId)
|
|
162
|
+
await recalculateWarningPoints(tx, fromUserId)
|
|
163
|
+
|
|
164
|
+
await tx.execute(sql`
|
|
165
|
+
update remember_tokens
|
|
166
|
+
set revoked_at = now(), revoked_reason = 'account_merge'
|
|
167
|
+
where user_id in (${fromUserId}, ${toUserId}) and revoked_at is null
|
|
168
|
+
`)
|
|
169
|
+
|
|
170
|
+
await tx.execute(sql`
|
|
171
|
+
update sessions
|
|
172
|
+
set revoked_at = now()
|
|
173
|
+
where user_id in (${fromUserId}, ${toUserId}) and revoked_at is null
|
|
174
|
+
`)
|
|
175
|
+
|
|
176
|
+
await tx.execute(sql`
|
|
177
|
+
update users
|
|
178
|
+
set deleted_at = now(), post_count = 0, thread_count = 0, reputation = 0,
|
|
179
|
+
updated_at = now()
|
|
180
|
+
where id = ${fromUserId}
|
|
181
|
+
`)
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function assertDistinct(fromUserId: number, toUserId: number): void {
|
|
187
|
+
if (fromUserId === toUserId) {
|
|
188
|
+
throw new ValidationError(msg('error.db.choose-two-different-accounts'))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function dedupeThenMove(
|
|
193
|
+
tx: Tx,
|
|
194
|
+
entry: DedupeColumn,
|
|
195
|
+
fromUserId: number,
|
|
196
|
+
toUserId: number,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
const predicate = entry.where === undefined ? sql`` : sql` and ${sql.raw(entry.where)}`
|
|
199
|
+
const matches = entry.keys.map(
|
|
200
|
+
(key) => sql`loser.${sql.raw(key)} is not distinct from winner.${sql.raw(key)}`,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
await tx.execute(sql`
|
|
204
|
+
delete from ${sql.raw(entry.table)} loser
|
|
205
|
+
where loser.${sql.raw(entry.column)} = ${fromUserId}${predicate}
|
|
206
|
+
and exists (
|
|
207
|
+
select 1 from ${sql.raw(entry.table)} winner
|
|
208
|
+
where winner.${sql.raw(entry.column)} = ${toUserId}
|
|
209
|
+
and ${sql.join(matches, sql` and `)}
|
|
210
|
+
)
|
|
211
|
+
`)
|
|
212
|
+
|
|
213
|
+
await tx.execute(sql`
|
|
214
|
+
update ${sql.raw(entry.table)}
|
|
215
|
+
set ${sql.raw(entry.column)} = ${toUserId}
|
|
216
|
+
where ${sql.raw(entry.column)} = ${fromUserId}
|
|
217
|
+
`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function mergeBespoke(
|
|
221
|
+
tx: Tx,
|
|
222
|
+
fromUserId: number,
|
|
223
|
+
toUserId: number,
|
|
224
|
+
): Promise<readonly number[]> {
|
|
225
|
+
const rated = new Set<number>()
|
|
226
|
+
|
|
227
|
+
const deleteRatings = async (query: ReturnType<typeof sql>): Promise<void> => {
|
|
228
|
+
const rows = resultRows(await tx.execute(query)) as Array<{ user_id: number }>
|
|
229
|
+
for (const row of rows) rated.add(Number(row.user_id))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await deleteRatings(sql`
|
|
233
|
+
delete from reputation
|
|
234
|
+
where (given_by_user_id = ${fromUserId} and user_id = ${toUserId})
|
|
235
|
+
or (given_by_user_id = ${toUserId} and user_id = ${fromUserId})
|
|
236
|
+
or (given_by_user_id = ${fromUserId} and user_id = ${fromUserId})
|
|
237
|
+
returning user_id
|
|
238
|
+
`)
|
|
239
|
+
|
|
240
|
+
await deleteRatings(sql`
|
|
241
|
+
delete from reputation loser
|
|
242
|
+
where loser.given_by_user_id = ${fromUserId}
|
|
243
|
+
and exists (
|
|
244
|
+
select 1 from reputation winner
|
|
245
|
+
where winner.given_by_user_id = ${toUserId}
|
|
246
|
+
and winner.post_id is not distinct from loser.post_id
|
|
247
|
+
and winner.user_id is not distinct from loser.user_id
|
|
248
|
+
)
|
|
249
|
+
returning loser.user_id
|
|
250
|
+
`)
|
|
251
|
+
await tx.execute(sql`
|
|
252
|
+
update reputation set given_by_user_id = ${toUserId}
|
|
253
|
+
where given_by_user_id = ${fromUserId}
|
|
254
|
+
`)
|
|
255
|
+
await deleteRatings(sql`
|
|
256
|
+
delete from reputation loser
|
|
257
|
+
where loser.user_id = ${fromUserId}
|
|
258
|
+
and exists (
|
|
259
|
+
select 1 from reputation winner
|
|
260
|
+
where winner.user_id = ${toUserId}
|
|
261
|
+
and winner.given_by_user_id = loser.given_by_user_id
|
|
262
|
+
and winner.post_id is not distinct from loser.post_id
|
|
263
|
+
)
|
|
264
|
+
returning loser.user_id
|
|
265
|
+
`)
|
|
266
|
+
await tx.execute(sql`
|
|
267
|
+
update reputation set user_id = ${toUserId} where user_id = ${fromUserId}
|
|
268
|
+
`)
|
|
269
|
+
|
|
270
|
+
await tx.execute(sql`
|
|
271
|
+
delete from user_relations
|
|
272
|
+
where (user_id = ${fromUserId} and other_user_id = ${toUserId})
|
|
273
|
+
or (user_id = ${toUserId} and other_user_id = ${fromUserId})
|
|
274
|
+
or (user_id = ${fromUserId} and other_user_id = ${fromUserId})
|
|
275
|
+
`)
|
|
276
|
+
|
|
277
|
+
await tx.execute(sql`
|
|
278
|
+
delete from user_relations loser
|
|
279
|
+
where loser.user_id = ${fromUserId}
|
|
280
|
+
and exists (
|
|
281
|
+
select 1 from user_relations winner
|
|
282
|
+
where winner.user_id = ${toUserId}
|
|
283
|
+
and winner.other_user_id = loser.other_user_id
|
|
284
|
+
)
|
|
285
|
+
`)
|
|
286
|
+
await tx.execute(sql`
|
|
287
|
+
update user_relations set user_id = ${toUserId} where user_id = ${fromUserId}
|
|
288
|
+
`)
|
|
289
|
+
|
|
290
|
+
await tx.execute(sql`
|
|
291
|
+
delete from user_relations loser
|
|
292
|
+
where loser.other_user_id = ${fromUserId}
|
|
293
|
+
and exists (
|
|
294
|
+
select 1 from user_relations winner
|
|
295
|
+
where winner.other_user_id = ${toUserId}
|
|
296
|
+
and winner.user_id = loser.user_id
|
|
297
|
+
)
|
|
298
|
+
`)
|
|
299
|
+
await tx.execute(sql`
|
|
300
|
+
update user_relations set other_user_id = ${toUserId} where other_user_id = ${fromUserId}
|
|
301
|
+
`)
|
|
302
|
+
|
|
303
|
+
return [...rated]
|
|
304
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { SQLWrapper } from 'drizzle-orm'
|
|
2
|
+
import { sql } from 'drizzle-orm'
|
|
3
|
+
|
|
4
|
+
import type { Database } from './client'
|
|
5
|
+
import { resultRows } from './result-rows'
|
|
6
|
+
|
|
7
|
+
interface CounterTransaction {
|
|
8
|
+
execute(query: SQLWrapper): Promise<unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface VisibilityChange {
|
|
12
|
+
readonly postId: number
|
|
13
|
+
readonly threadId: number
|
|
14
|
+
readonly forumId: number
|
|
15
|
+
readonly authorId: number | null
|
|
16
|
+
readonly isFirstPost: boolean
|
|
17
|
+
readonly delta: 1 | -1
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function repairThreadLastPost(
|
|
21
|
+
tx: CounterTransaction,
|
|
22
|
+
threadId: number,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
await tx.execute(sql`
|
|
25
|
+
with newest as (
|
|
26
|
+
select id, author_user_id, author_username, created_at
|
|
27
|
+
from posts
|
|
28
|
+
where thread_id = ${threadId} and visibility = 'visible'
|
|
29
|
+
order by id desc
|
|
30
|
+
limit 1
|
|
31
|
+
)
|
|
32
|
+
update threads t
|
|
33
|
+
set last_post_id = (select id from newest),
|
|
34
|
+
last_post_user_id = (select author_user_id from newest),
|
|
35
|
+
last_post_username = (select author_username from newest),
|
|
36
|
+
last_post_at = coalesce((select created_at from newest), t.created_at),
|
|
37
|
+
updated_at = now()
|
|
38
|
+
where t.id = ${threadId}
|
|
39
|
+
`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function repairForumLastPostChain(
|
|
43
|
+
tx: CounterTransaction,
|
|
44
|
+
forumId: number,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const chain = resultRows(
|
|
47
|
+
await tx.execute(sql`
|
|
48
|
+
select f.id
|
|
49
|
+
from forums f
|
|
50
|
+
join forums child on child.id = ${forumId}
|
|
51
|
+
where f.id = child.id or child.path like f.path || '.%'
|
|
52
|
+
order by f.depth desc
|
|
53
|
+
`),
|
|
54
|
+
) as Array<{ id: number }>
|
|
55
|
+
|
|
56
|
+
for (const forum of chain) {
|
|
57
|
+
await tx.execute(sql`
|
|
58
|
+
with own as (
|
|
59
|
+
select t.last_post_id as post_id, t.last_post_at as at, t.id as thread_id,
|
|
60
|
+
t.title as thread_title, t.last_post_user_id as user_id,
|
|
61
|
+
t.last_post_username as username
|
|
62
|
+
from threads t
|
|
63
|
+
where t.forum_id = ${forum.id}
|
|
64
|
+
and t.visibility = 'visible'
|
|
65
|
+
and t.last_post_id is not null
|
|
66
|
+
order by t.last_post_at desc, t.last_post_id desc
|
|
67
|
+
limit 1
|
|
68
|
+
),
|
|
69
|
+
kid as (
|
|
70
|
+
select c.last_post_id as post_id, c.last_post_at as at,
|
|
71
|
+
c.last_post_thread_id as thread_id, c.last_post_thread_title as thread_title,
|
|
72
|
+
c.last_post_user_id as user_id, c.last_post_username as username
|
|
73
|
+
from forums c
|
|
74
|
+
where c.parent_id = ${forum.id} and c.last_post_id is not null
|
|
75
|
+
order by c.last_post_at desc, c.last_post_id desc
|
|
76
|
+
limit 1
|
|
77
|
+
),
|
|
78
|
+
best as (
|
|
79
|
+
select * from (select * from own union all select * from kid) candidates
|
|
80
|
+
order by at desc, post_id desc
|
|
81
|
+
limit 1
|
|
82
|
+
)
|
|
83
|
+
update forums f
|
|
84
|
+
set last_post_id = (select post_id from best),
|
|
85
|
+
last_post_thread_id = (select thread_id from best),
|
|
86
|
+
last_post_thread_title = (select thread_title from best),
|
|
87
|
+
last_post_user_id = (select user_id from best),
|
|
88
|
+
last_post_username = (select username from best),
|
|
89
|
+
last_post_at = (select at from best),
|
|
90
|
+
updated_at = now()
|
|
91
|
+
where f.id = ${forum.id}
|
|
92
|
+
`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function applyVisibilityChangeCounters(
|
|
97
|
+
tx: CounterTransaction,
|
|
98
|
+
change: VisibilityChange,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const threadDelta = change.isFirstPost ? change.delta : 0
|
|
101
|
+
const replyDelta = change.isFirstPost ? 0 : change.delta
|
|
102
|
+
|
|
103
|
+
await tx.execute(sql`
|
|
104
|
+
update forums
|
|
105
|
+
set post_count = greatest(post_count + ${change.delta}, 0),
|
|
106
|
+
thread_count = greatest(thread_count + ${threadDelta}, 0),
|
|
107
|
+
updated_at = now()
|
|
108
|
+
where id = ${change.forumId}
|
|
109
|
+
`)
|
|
110
|
+
|
|
111
|
+
await tx.execute(sql`
|
|
112
|
+
update threads
|
|
113
|
+
set reply_count = greatest(reply_count + ${replyDelta}, 0),
|
|
114
|
+
updated_at = now()
|
|
115
|
+
where id = ${change.threadId}
|
|
116
|
+
`)
|
|
117
|
+
|
|
118
|
+
if (change.authorId !== null) {
|
|
119
|
+
await tx.execute(sql`
|
|
120
|
+
update users
|
|
121
|
+
set post_count = greatest(post_count + ${change.delta}, 0),
|
|
122
|
+
thread_count = greatest(thread_count + ${threadDelta}, 0),
|
|
123
|
+
updated_at = now()
|
|
124
|
+
where id = ${change.authorId}
|
|
125
|
+
`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await repairThreadLastPost(tx, change.threadId)
|
|
129
|
+
await repairForumLastPostChain(tx, change.forumId)
|
|
130
|
+
|
|
131
|
+
await tx.execute(sql`
|
|
132
|
+
insert into outbox (topic, payload)
|
|
133
|
+
values (
|
|
134
|
+
'post.visibility_changed',
|
|
135
|
+
${JSON.stringify({
|
|
136
|
+
postId: change.postId,
|
|
137
|
+
threadId: change.threadId,
|
|
138
|
+
forumId: change.forumId,
|
|
139
|
+
visible: change.delta === 1,
|
|
140
|
+
})}::jsonb
|
|
141
|
+
)
|
|
142
|
+
`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function applyAncestorVisibilityChange(
|
|
146
|
+
db: Database,
|
|
147
|
+
postId: number,
|
|
148
|
+
): Promise<boolean> {
|
|
149
|
+
return db.transaction(async (tx) => {
|
|
150
|
+
const found = resultRows(
|
|
151
|
+
await tx.execute(sql`
|
|
152
|
+
select visibility, forum_id, is_first_post from posts where id = ${postId}
|
|
153
|
+
`),
|
|
154
|
+
) as Array<{ visibility: string; forum_id: number; is_first_post: boolean }>
|
|
155
|
+
|
|
156
|
+
const post = found[0]
|
|
157
|
+
if (!post) return false
|
|
158
|
+
|
|
159
|
+
const shouldCount = post.visibility === 'visible'
|
|
160
|
+
if (shouldCount) {
|
|
161
|
+
const claimed = await tx.execute(sql`
|
|
162
|
+
insert into content_counter_rollups (post_id) values (${postId})
|
|
163
|
+
on conflict (post_id) do nothing
|
|
164
|
+
returning post_id
|
|
165
|
+
`)
|
|
166
|
+
if (resultRows(claimed).length === 0) return false
|
|
167
|
+
} else {
|
|
168
|
+
const released = await tx.execute(sql`
|
|
169
|
+
delete from content_counter_rollups where post_id = ${postId} returning post_id
|
|
170
|
+
`)
|
|
171
|
+
if (resultRows(released).length === 0) return false
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const delta = shouldCount ? 1 : -1
|
|
175
|
+
const threadDelta = post.is_first_post ? delta : 0
|
|
176
|
+
|
|
177
|
+
await tx.execute(sql`
|
|
178
|
+
update forums f
|
|
179
|
+
set post_count = greatest(f.post_count + ${delta}, 0),
|
|
180
|
+
thread_count = greatest(f.thread_count + ${threadDelta}, 0),
|
|
181
|
+
updated_at = now()
|
|
182
|
+
from forums child
|
|
183
|
+
where child.id = ${post.forum_id}
|
|
184
|
+
and f.id <> child.id
|
|
185
|
+
and child.path like f.path || '.%'
|
|
186
|
+
`)
|
|
187
|
+
|
|
188
|
+
return true
|
|
189
|
+
})
|
|
190
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SQL, sql } from 'drizzle-orm'
|
|
2
|
+
import type { PgColumn } from 'drizzle-orm/pg-core'
|
|
3
|
+
|
|
4
|
+
import type { ContentScope } from '@meith/core'
|
|
5
|
+
|
|
6
|
+
export function visibleIn(column: PgColumn | SQL, scope: ContentScope): SQL {
|
|
7
|
+
const first = scope.states[0]
|
|
8
|
+
if (scope.states.length === 1) return sql`${column} = ${first}`
|
|
9
|
+
return sql`${column} in (${sql.join(
|
|
10
|
+
scope.states.map((state) => sql`${state}`),
|
|
11
|
+
sql`, `,
|
|
12
|
+
)})`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const PENDING_APPROVAL = 'unapproved' as const
|
|
16
|
+
|
|
17
|
+
export const VISIBLE = 'visible' as const
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type BoardVocabulary,
|
|
5
|
+
CORE_RENDERING,
|
|
6
|
+
compileVocabulary,
|
|
7
|
+
type MarkdownPipeline,
|
|
8
|
+
NO_VOCABULARY_SOURCE,
|
|
9
|
+
type VocabularySource,
|
|
10
|
+
} from '@meith/markdown'
|
|
11
|
+
|
|
12
|
+
import type { Database } from './client'
|
|
13
|
+
import { resultRows } from './result-rows'
|
|
14
|
+
|
|
15
|
+
export async function readVocabularySource(db: Database): Promise<VocabularySource | null> {
|
|
16
|
+
return db.transaction(async (tx) => {
|
|
17
|
+
const revision = resultRows(
|
|
18
|
+
await tx.execute(sql`select version from cache_versions where key = 'markdown_vocabulary'`),
|
|
19
|
+
) as Array<{ version: number }>
|
|
20
|
+
|
|
21
|
+
if (Number(revision[0]?.version ?? 0) === 0) return null
|
|
22
|
+
|
|
23
|
+
const smilies = resultRows(
|
|
24
|
+
await tx.execute(sql`select code, src, alt from smilies where enabled = true order by id`),
|
|
25
|
+
) as Array<Record<string, unknown>>
|
|
26
|
+
|
|
27
|
+
const directives = resultRows(
|
|
28
|
+
await tx.execute(
|
|
29
|
+
sql`select name, block from custom_directives where enabled = true order by name`,
|
|
30
|
+
),
|
|
31
|
+
) as Array<Record<string, unknown>>
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
revision: Number(revision[0]?.version ?? 0),
|
|
35
|
+
smilies: smilies.map((row) => ({
|
|
36
|
+
code: String(row.code),
|
|
37
|
+
src: String(row.src),
|
|
38
|
+
...(row.alt === null ? {} : { alt: String(row.alt) }),
|
|
39
|
+
})),
|
|
40
|
+
directives: directives.map((row) => ({
|
|
41
|
+
name: String(row.name),
|
|
42
|
+
block: row.block === true,
|
|
43
|
+
})),
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readBoardVocabulary(
|
|
49
|
+
db: Database,
|
|
50
|
+
pipeline: MarkdownPipeline = CORE_RENDERING,
|
|
51
|
+
): Promise<BoardVocabulary> {
|
|
52
|
+
const source = await pipeline.vocabulary((await readVocabularySource(db)) ?? NO_VOCABULARY_SOURCE)
|
|
53
|
+
return compileVocabulary(source)
|
|
54
|
+
}
|