@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,253 @@
|
|
|
1
|
+
import { type SQL, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { IpMatch, ModCpRepository, ModLogEntry, ModLogPage } from '@meith/moderation'
|
|
4
|
+
import { MOD_LOG_ACTIONS } from '@meith/moderation'
|
|
5
|
+
|
|
6
|
+
import type { Database } from './client'
|
|
7
|
+
import { decodeCursor, encodeCursor } from './cursor'
|
|
8
|
+
import { resultRows } from './result-rows'
|
|
9
|
+
import { idList, textList } from './sql-lists'
|
|
10
|
+
import { PENDING_APPROVAL } from './visibility'
|
|
11
|
+
|
|
12
|
+
interface LogRow {
|
|
13
|
+
id: number
|
|
14
|
+
action: string
|
|
15
|
+
user_id: number | null
|
|
16
|
+
username: string | null
|
|
17
|
+
forum_id: number | null
|
|
18
|
+
forum_title: string | null
|
|
19
|
+
detail: Record<string, unknown> | null
|
|
20
|
+
created_at: Date
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DETAIL_LABELS: Readonly<Record<string, string>> = {
|
|
24
|
+
threadId: 'Thread',
|
|
25
|
+
threadIds: 'Threads',
|
|
26
|
+
targetThreadId: 'Into thread',
|
|
27
|
+
newThreadId: 'New thread',
|
|
28
|
+
postId: 'Post',
|
|
29
|
+
postIds: 'Posts',
|
|
30
|
+
forumId: 'Forum',
|
|
31
|
+
fromForumId: 'From forum',
|
|
32
|
+
toForumId: 'To forum',
|
|
33
|
+
applied: 'Applied to',
|
|
34
|
+
posts: 'Posts affected',
|
|
35
|
+
userId: 'Member',
|
|
36
|
+
warningId: 'Warning',
|
|
37
|
+
points: 'Points',
|
|
38
|
+
total: 'New total',
|
|
39
|
+
matches: 'Accounts found',
|
|
40
|
+
subjectUserId: 'Member',
|
|
41
|
+
reportId: 'Report',
|
|
42
|
+
value: 'Set to',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function flattenDetail(
|
|
46
|
+
detail: Record<string, unknown> | null,
|
|
47
|
+
): readonly { label: string; value: string }[] {
|
|
48
|
+
if (detail === null) return []
|
|
49
|
+
const out: { label: string; value: string }[] = []
|
|
50
|
+
for (const [key, label] of Object.entries(DETAIL_LABELS)) {
|
|
51
|
+
const value = detail[key]
|
|
52
|
+
if (value === undefined || value === null) continue
|
|
53
|
+
const rendered = Array.isArray(value)
|
|
54
|
+
? value.slice(0, 20).map(String).join(', ')
|
|
55
|
+
: String(value)
|
|
56
|
+
if (rendered === '') continue
|
|
57
|
+
out.push({ label, value: rendered })
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function forumScope(forumIds: readonly number[]): SQL {
|
|
63
|
+
if (forumIds.length === 0) return sql`false`
|
|
64
|
+
return sql.join(
|
|
65
|
+
forumIds.map(
|
|
66
|
+
(id) => sql`(
|
|
67
|
+
l.detail->'forumIds' @> ${String(id)}::jsonb
|
|
68
|
+
or l.detail->'forumId' = ${String(id)}::jsonb
|
|
69
|
+
or l.detail->'toForumId' = ${String(id)}::jsonb
|
|
70
|
+
)`,
|
|
71
|
+
),
|
|
72
|
+
sql` or `,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class PostgresModCpRepository implements ModCpRepository {
|
|
77
|
+
constructor(private readonly db: Database) {}
|
|
78
|
+
|
|
79
|
+
async log(input: {
|
|
80
|
+
readonly forumIds: readonly number[]
|
|
81
|
+
readonly actorUserId: number
|
|
82
|
+
readonly limit: number
|
|
83
|
+
readonly after?: string | undefined
|
|
84
|
+
}): Promise<ModLogPage> {
|
|
85
|
+
const cursor = input.after === undefined ? null : decodeCursor(input.after)
|
|
86
|
+
const after = cursor ? sql`and (e.created_at, e.id) < (${cursor.at}, ${cursor.id})` : sql``
|
|
87
|
+
|
|
88
|
+
const rows = resultRows(
|
|
89
|
+
await this.db.execute(sql`
|
|
90
|
+
with entry as (
|
|
91
|
+
select l.id, l.action, l.user_id, l.created_at, l.detail,
|
|
92
|
+
coalesce(
|
|
93
|
+
(l.detail->>'forumId')::int,
|
|
94
|
+
(l.detail->>'toForumId')::int
|
|
95
|
+
) as forum_id
|
|
96
|
+
from admin_log l
|
|
97
|
+
where l.action in ${textList(MOD_LOG_ACTIONS)}
|
|
98
|
+
and (
|
|
99
|
+
l.user_id = ${input.actorUserId}
|
|
100
|
+
or ${forumScope(input.forumIds)}
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
select e.id, e.action, e.user_id, u.username, e.forum_id,
|
|
104
|
+
f.title as forum_title, e.detail, e.created_at
|
|
105
|
+
from entry e
|
|
106
|
+
left join users u on u.id = e.user_id
|
|
107
|
+
left join forums f on f.id = e.forum_id
|
|
108
|
+
where true ${after}
|
|
109
|
+
order by e.created_at desc, e.id desc
|
|
110
|
+
limit ${input.limit + 1}
|
|
111
|
+
`),
|
|
112
|
+
) as LogRow[]
|
|
113
|
+
|
|
114
|
+
const entries = rows.slice(0, input.limit).map(
|
|
115
|
+
(row): ModLogEntry => ({
|
|
116
|
+
id: Number(row.id),
|
|
117
|
+
action: row.action,
|
|
118
|
+
actorUserId: row.user_id === null ? null : Number(row.user_id),
|
|
119
|
+
actorUsername: row.username,
|
|
120
|
+
forumId: row.forum_id === null ? null : Number(row.forum_id),
|
|
121
|
+
forumTitle: row.forum_title,
|
|
122
|
+
detail: flattenDetail(row.detail),
|
|
123
|
+
at: new Date(row.created_at),
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
const last = entries.at(-1)
|
|
127
|
+
return rows.length > input.limit && last
|
|
128
|
+
? { entries, nextCursor: encodeCursor(last.at, last.id) }
|
|
129
|
+
: { entries }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async workload(
|
|
133
|
+
forumIds: readonly number[],
|
|
134
|
+
): Promise<ReadonlyMap<number, { pending: number; openReports: number }>> {
|
|
135
|
+
if (forumIds.length === 0) return new Map()
|
|
136
|
+
|
|
137
|
+
const rows = resultRows(
|
|
138
|
+
await this.db.execute(sql`
|
|
139
|
+
with held as (
|
|
140
|
+
select forum_id, count(*)::int as n from threads
|
|
141
|
+
where forum_id in ${idList(forumIds)} and visibility = ${PENDING_APPROVAL}
|
|
142
|
+
group by forum_id
|
|
143
|
+
union all
|
|
144
|
+
select p.forum_id, count(*)::int from posts p
|
|
145
|
+
join threads t on t.id = p.thread_id
|
|
146
|
+
where p.forum_id in ${idList(forumIds)}
|
|
147
|
+
and p.visibility = ${PENDING_APPROVAL}
|
|
148
|
+
and t.visibility <> ${PENDING_APPROVAL}
|
|
149
|
+
and p.is_first_post = false
|
|
150
|
+
group by p.forum_id
|
|
151
|
+
),
|
|
152
|
+
reported as (
|
|
153
|
+
select forum_id, count(*)::int as n from reports
|
|
154
|
+
where forum_id in ${idList(forumIds)} and status = 'open'
|
|
155
|
+
group by forum_id
|
|
156
|
+
)
|
|
157
|
+
select f.id as forum_id,
|
|
158
|
+
coalesce((select sum(n) from held where held.forum_id = f.id), 0)::int as pending,
|
|
159
|
+
coalesce((select n from reported where reported.forum_id = f.id), 0)::int as open_reports
|
|
160
|
+
from forums f
|
|
161
|
+
where f.id in ${idList(forumIds)}
|
|
162
|
+
`),
|
|
163
|
+
) as Array<{ forum_id: number; pending: number; open_reports: number }>
|
|
164
|
+
|
|
165
|
+
return new Map(
|
|
166
|
+
rows.map((row) => [
|
|
167
|
+
Number(row.forum_id),
|
|
168
|
+
{ pending: Number(row.pending), openReports: Number(row.open_reports) },
|
|
169
|
+
]),
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async ipPrefixesFor(
|
|
174
|
+
userId: number,
|
|
175
|
+
): Promise<{ registration: string | null; lastVisit: string | null }> {
|
|
176
|
+
const rows = resultRows(
|
|
177
|
+
await this.db.execute(sql`
|
|
178
|
+
select registration_ip_prefix, last_ip_prefix from users where id = ${userId}
|
|
179
|
+
`),
|
|
180
|
+
) as Array<{ registration_ip_prefix: string | null; last_ip_prefix: string | null }>
|
|
181
|
+
const row = rows[0]
|
|
182
|
+
if (!row) return { registration: null, lastVisit: null }
|
|
183
|
+
return { registration: row.registration_ip_prefix, lastVisit: row.last_ip_prefix }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async ipMatches(userId: number, limit: number): Promise<readonly IpMatch[]> {
|
|
187
|
+
const rows = resultRows(
|
|
188
|
+
await this.db.execute(sql`
|
|
189
|
+
with subject as (
|
|
190
|
+
select registration_ip_prefix as reg, last_ip_prefix as last
|
|
191
|
+
from users where id = ${userId}
|
|
192
|
+
)
|
|
193
|
+
select u.id, u.username, u.last_active_at,
|
|
194
|
+
(u.registration_ip_prefix is not null
|
|
195
|
+
and u.registration_ip_prefix in (
|
|
196
|
+
(select reg from subject), (select last from subject)
|
|
197
|
+
)) as by_registration,
|
|
198
|
+
(u.last_ip_prefix is not null
|
|
199
|
+
and u.last_ip_prefix in (
|
|
200
|
+
(select reg from subject), (select last from subject)
|
|
201
|
+
)) as by_last
|
|
202
|
+
from users u, subject s
|
|
203
|
+
where u.id <> ${userId}
|
|
204
|
+
and u.state <> 'deleted'
|
|
205
|
+
and (
|
|
206
|
+
(u.registration_ip_prefix is not null
|
|
207
|
+
and u.registration_ip_prefix in (s.reg, s.last))
|
|
208
|
+
or (u.last_ip_prefix is not null and u.last_ip_prefix in (s.reg, s.last))
|
|
209
|
+
)
|
|
210
|
+
order by u.last_active_at desc nulls last, u.id
|
|
211
|
+
limit ${limit}
|
|
212
|
+
`),
|
|
213
|
+
) as Array<{
|
|
214
|
+
id: number
|
|
215
|
+
username: string
|
|
216
|
+
last_active_at: Date | null
|
|
217
|
+
by_registration: boolean
|
|
218
|
+
by_last: boolean
|
|
219
|
+
}>
|
|
220
|
+
|
|
221
|
+
return rows.map((row) => ({
|
|
222
|
+
userId: Number(row.id),
|
|
223
|
+
username: row.username,
|
|
224
|
+
matchedOn:
|
|
225
|
+
row.by_registration && row.by_last
|
|
226
|
+
? 'both'
|
|
227
|
+
: row.by_registration
|
|
228
|
+
? 'registration'
|
|
229
|
+
: 'last_visit',
|
|
230
|
+
lastActiveAt: row.last_active_at === null ? null : new Date(row.last_active_at),
|
|
231
|
+
}))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async recordIpLookup(input: {
|
|
235
|
+
readonly actorUserId: number
|
|
236
|
+
readonly subjectUserId: number
|
|
237
|
+
readonly matches: number
|
|
238
|
+
readonly at: Date
|
|
239
|
+
}): Promise<void> {
|
|
240
|
+
await this.db.execute(sql`
|
|
241
|
+
insert into admin_log (user_id, action, detail, created_at)
|
|
242
|
+
values (
|
|
243
|
+
${input.actorUserId},
|
|
244
|
+
'modcp.ip_lookup',
|
|
245
|
+
${JSON.stringify({
|
|
246
|
+
subjectUserId: input.subjectUserId,
|
|
247
|
+
matches: input.matches,
|
|
248
|
+
})}::jsonb,
|
|
249
|
+
${input.at}
|
|
250
|
+
)
|
|
251
|
+
`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ModerationQueueRepository,
|
|
5
|
+
PendingItem,
|
|
6
|
+
QueueDecision,
|
|
7
|
+
QueueItem,
|
|
8
|
+
QueuePage,
|
|
9
|
+
QueueSelection,
|
|
10
|
+
} from '@meith/moderation'
|
|
11
|
+
|
|
12
|
+
import type { Database } from './client'
|
|
13
|
+
import { resultRows } from './result-rows'
|
|
14
|
+
import { idList } from './sql-lists'
|
|
15
|
+
import { PENDING_APPROVAL, VISIBLE } from './visibility'
|
|
16
|
+
import { applyVisibilityChangeCounters } from './visibility-counters'
|
|
17
|
+
|
|
18
|
+
const EXCERPT = 300
|
|
19
|
+
|
|
20
|
+
interface QueueRow {
|
|
21
|
+
kind: 'thread' | 'post'
|
|
22
|
+
id: number
|
|
23
|
+
forum_id: number
|
|
24
|
+
forum_title: string
|
|
25
|
+
thread_id: number
|
|
26
|
+
thread_slug: string
|
|
27
|
+
thread_title: string
|
|
28
|
+
author_user_id: number | null
|
|
29
|
+
author_username: string
|
|
30
|
+
excerpt: string | null
|
|
31
|
+
created_at: Date
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function encodeCursor(row: QueueItem): string {
|
|
35
|
+
return Buffer.from(`${row.createdAt.toISOString()}|${row.kind}|${row.id}`, 'utf8').toString(
|
|
36
|
+
'base64url',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function decodeCursor(value: string): { at: Date; kind: string; id: number } | null {
|
|
41
|
+
try {
|
|
42
|
+
const [at, kind, id] = Buffer.from(value, 'base64url').toString('utf8').split('|')
|
|
43
|
+
if (at === undefined || kind === undefined || id === undefined) return null
|
|
44
|
+
const when = new Date(at)
|
|
45
|
+
const numeric = Number(id)
|
|
46
|
+
if (Number.isNaN(when.getTime()) || !Number.isSafeInteger(numeric)) return null
|
|
47
|
+
return { at: when, kind, id: numeric }
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toItem(row: QueueRow): QueueItem {
|
|
54
|
+
return {
|
|
55
|
+
kind: row.kind,
|
|
56
|
+
id: Number(row.id),
|
|
57
|
+
forumId: Number(row.forum_id),
|
|
58
|
+
forumTitle: row.forum_title,
|
|
59
|
+
threadId: Number(row.thread_id),
|
|
60
|
+
threadSlug: row.thread_slug,
|
|
61
|
+
threadTitle: row.thread_title,
|
|
62
|
+
authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
|
|
63
|
+
authorUsername: row.author_username,
|
|
64
|
+
excerpt: row.excerpt ?? '',
|
|
65
|
+
createdAt: new Date(row.created_at),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class PostgresModerationQueueRepository implements ModerationQueueRepository {
|
|
70
|
+
constructor(private readonly db: Database) {}
|
|
71
|
+
|
|
72
|
+
async list(
|
|
73
|
+
forumIds: readonly number[],
|
|
74
|
+
options: {
|
|
75
|
+
readonly limit: number
|
|
76
|
+
readonly after?: string
|
|
77
|
+
readonly offset?: number
|
|
78
|
+
},
|
|
79
|
+
): Promise<QueuePage> {
|
|
80
|
+
if (forumIds.length === 0) return { items: [] }
|
|
81
|
+
|
|
82
|
+
const cursor = options.after === undefined ? null : decodeCursor(options.after)
|
|
83
|
+
const after = cursor
|
|
84
|
+
? sql`and (q.created_at, q.kind, q.id) > (${cursor.at}, ${cursor.kind}, ${cursor.id})`
|
|
85
|
+
: sql``
|
|
86
|
+
|
|
87
|
+
const rows = resultRows(
|
|
88
|
+
await this.db.execute(sql`
|
|
89
|
+
with q as (
|
|
90
|
+
select 'thread'::text as kind, t.id, t.forum_id, f.title as forum_title,
|
|
91
|
+
t.id as thread_id, t.slug as thread_slug, t.title as thread_title,
|
|
92
|
+
t.author_user_id, t.author_username,
|
|
93
|
+
left(p.message, ${EXCERPT}) as excerpt, t.created_at
|
|
94
|
+
from threads t
|
|
95
|
+
join forums f on f.id = t.forum_id
|
|
96
|
+
left join posts p on p.id = t.first_post_id
|
|
97
|
+
where t.forum_id in ${idList(forumIds)}
|
|
98
|
+
and t.visibility = ${PENDING_APPROVAL}
|
|
99
|
+
union all
|
|
100
|
+
select 'post'::text, p.id, p.forum_id, f.title,
|
|
101
|
+
p.thread_id, t.slug, t.title,
|
|
102
|
+
p.author_user_id, p.author_username,
|
|
103
|
+
left(p.message, ${EXCERPT}), p.created_at
|
|
104
|
+
from posts p
|
|
105
|
+
join threads t on t.id = p.thread_id
|
|
106
|
+
join forums f on f.id = p.forum_id
|
|
107
|
+
where p.forum_id in ${idList(forumIds)}
|
|
108
|
+
and p.visibility = ${PENDING_APPROVAL}
|
|
109
|
+
and t.visibility = ${VISIBLE}
|
|
110
|
+
and p.is_first_post = false
|
|
111
|
+
)
|
|
112
|
+
select * from q
|
|
113
|
+
where true ${after}
|
|
114
|
+
order by q.created_at, q.kind, q.id
|
|
115
|
+
limit ${options.limit + 1} offset ${options.offset ?? 0}
|
|
116
|
+
`),
|
|
117
|
+
) as QueueRow[]
|
|
118
|
+
|
|
119
|
+
const items = rows.slice(0, options.limit).map(toItem)
|
|
120
|
+
const last = items.at(-1)
|
|
121
|
+
return rows.length > options.limit && last
|
|
122
|
+
? { items, nextCursor: encodeCursor(last) }
|
|
123
|
+
: { items }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async countPending(forumIds: readonly number[]): Promise<number> {
|
|
127
|
+
if (forumIds.length === 0) return 0
|
|
128
|
+
const rows = resultRows(
|
|
129
|
+
await this.db.execute(sql`
|
|
130
|
+
select
|
|
131
|
+
(select count(*) from threads
|
|
132
|
+
where forum_id in ${idList(forumIds)} and visibility = ${PENDING_APPROVAL})
|
|
133
|
+
+
|
|
134
|
+
(select count(*) from posts p
|
|
135
|
+
join threads t on t.id = p.thread_id
|
|
136
|
+
where p.forum_id in ${idList(forumIds)}
|
|
137
|
+
and p.visibility = ${PENDING_APPROVAL}
|
|
138
|
+
and t.visibility = ${VISIBLE}
|
|
139
|
+
and p.is_first_post = false)
|
|
140
|
+
as pending
|
|
141
|
+
`),
|
|
142
|
+
) as Array<{ pending: number }>
|
|
143
|
+
return Number(rows[0]?.pending ?? 0)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async resolve(selection: readonly QueueSelection[]): Promise<readonly PendingItem[]> {
|
|
147
|
+
const threadIds = selection.filter((s) => s.kind === 'thread').map((s) => s.id)
|
|
148
|
+
const postIds = selection.filter((s) => s.kind === 'post').map((s) => s.id)
|
|
149
|
+
if (threadIds.length === 0 && postIds.length === 0) return []
|
|
150
|
+
|
|
151
|
+
const rows = resultRows(
|
|
152
|
+
await this.db.execute(sql`
|
|
153
|
+
select 'thread'::text as kind, id, forum_id from threads
|
|
154
|
+
where id in ${idList(threadIds)} and visibility = ${PENDING_APPROVAL}
|
|
155
|
+
union all
|
|
156
|
+
select 'post'::text, p.id, p.forum_id from posts p
|
|
157
|
+
join threads t on t.id = p.thread_id
|
|
158
|
+
where p.id in ${idList(postIds)}
|
|
159
|
+
and p.visibility = ${PENDING_APPROVAL}
|
|
160
|
+
and t.visibility = ${VISIBLE}
|
|
161
|
+
`),
|
|
162
|
+
) as Array<{ kind: 'thread' | 'post'; id: number; forum_id: number }>
|
|
163
|
+
|
|
164
|
+
return rows.map((row) => ({
|
|
165
|
+
kind: row.kind,
|
|
166
|
+
id: Number(row.id),
|
|
167
|
+
forumId: Number(row.forum_id),
|
|
168
|
+
}))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async apply(input: {
|
|
172
|
+
readonly decision: QueueDecision
|
|
173
|
+
readonly threadIds: readonly number[]
|
|
174
|
+
readonly postIds: readonly number[]
|
|
175
|
+
readonly actorUserId: number
|
|
176
|
+
readonly at: Date
|
|
177
|
+
}): Promise<number> {
|
|
178
|
+
if (input.threadIds.length === 0 && input.postIds.length === 0) return 0
|
|
179
|
+
const approving = input.decision === 'approve'
|
|
180
|
+
const to = approving ? 'visible' : 'deleted'
|
|
181
|
+
|
|
182
|
+
return this.db.transaction(async (tx) => {
|
|
183
|
+
let applied = 0
|
|
184
|
+
const touched = new Set<number>()
|
|
185
|
+
|
|
186
|
+
for (const threadId of input.threadIds) {
|
|
187
|
+
const moved = resultRows(
|
|
188
|
+
await tx.execute(sql`
|
|
189
|
+
update threads set visibility = ${to}, updated_at = now()
|
|
190
|
+
where id = ${threadId} and visibility = ${PENDING_APPROVAL}
|
|
191
|
+
returning id, forum_id, first_post_id, author_user_id
|
|
192
|
+
`),
|
|
193
|
+
) as Array<{
|
|
194
|
+
id: number
|
|
195
|
+
forum_id: number
|
|
196
|
+
first_post_id: number | null
|
|
197
|
+
author_user_id: number | null
|
|
198
|
+
}>
|
|
199
|
+
const thread = moved[0]
|
|
200
|
+
if (!thread) continue
|
|
201
|
+
applied += 1
|
|
202
|
+
touched.add(Number(thread.forum_id))
|
|
203
|
+
|
|
204
|
+
if (thread.first_post_id !== null) {
|
|
205
|
+
const post = resultRows(
|
|
206
|
+
await tx.execute(sql`
|
|
207
|
+
update posts set visibility = ${to}
|
|
208
|
+
where id = ${thread.first_post_id} and visibility = ${PENDING_APPROVAL}
|
|
209
|
+
returning id
|
|
210
|
+
`),
|
|
211
|
+
) as Array<{ id: number }>
|
|
212
|
+
|
|
213
|
+
if (post[0] && approving) {
|
|
214
|
+
await applyVisibilityChangeCounters(tx, {
|
|
215
|
+
postId: Number(post[0].id),
|
|
216
|
+
threadId: Number(thread.id),
|
|
217
|
+
forumId: Number(thread.forum_id),
|
|
218
|
+
authorId: thread.author_user_id === null ? null : Number(thread.author_user_id),
|
|
219
|
+
isFirstPost: true,
|
|
220
|
+
delta: 1,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const postId of input.postIds) {
|
|
227
|
+
const moved = resultRows(
|
|
228
|
+
await tx.execute(sql`
|
|
229
|
+
update posts set visibility = ${to}
|
|
230
|
+
where id = ${postId} and visibility = ${PENDING_APPROVAL}
|
|
231
|
+
returning id, thread_id, forum_id, author_user_id, is_first_post
|
|
232
|
+
`),
|
|
233
|
+
) as Array<{
|
|
234
|
+
id: number
|
|
235
|
+
thread_id: number
|
|
236
|
+
forum_id: number
|
|
237
|
+
author_user_id: number | null
|
|
238
|
+
is_first_post: boolean
|
|
239
|
+
}>
|
|
240
|
+
const post = moved[0]
|
|
241
|
+
if (!post) continue
|
|
242
|
+
applied += 1
|
|
243
|
+
touched.add(Number(post.forum_id))
|
|
244
|
+
|
|
245
|
+
if (approving) {
|
|
246
|
+
await applyVisibilityChangeCounters(tx, {
|
|
247
|
+
postId: Number(post.id),
|
|
248
|
+
threadId: Number(post.thread_id),
|
|
249
|
+
forumId: Number(post.forum_id),
|
|
250
|
+
authorId: post.author_user_id === null ? null : Number(post.author_user_id),
|
|
251
|
+
isFirstPost: post.is_first_post,
|
|
252
|
+
delta: 1,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (applied > 0) {
|
|
258
|
+
const forumIds = [...touched]
|
|
259
|
+
await tx.execute(sql`
|
|
260
|
+
insert into admin_log (user_id, action, detail, created_at)
|
|
261
|
+
values (
|
|
262
|
+
${input.actorUserId},
|
|
263
|
+
${`moderation.${input.decision}`},
|
|
264
|
+
${JSON.stringify({
|
|
265
|
+
threadIds: input.threadIds,
|
|
266
|
+
postIds: input.postIds,
|
|
267
|
+
...(forumIds.length === 1 ? { forumId: forumIds[0] } : {}),
|
|
268
|
+
forumIds,
|
|
269
|
+
applied,
|
|
270
|
+
})}::jsonb,
|
|
271
|
+
${input.at}
|
|
272
|
+
)
|
|
273
|
+
`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return applied
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
}
|