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