@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
package/src/feed-repo.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { ContentScope } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { toDate } from './row-values'
|
|
8
|
+
import { audienceIsEmpty, inAudience } from './thread-audience'
|
|
9
|
+
import { visibleIn } from './visibility'
|
|
10
|
+
|
|
11
|
+
export interface FeedScope {
|
|
12
|
+
readonly forumIds: readonly number[]
|
|
13
|
+
readonly ownThreadsOnlyForumIds: readonly number[]
|
|
14
|
+
readonly viewerUserId: number | null
|
|
15
|
+
readonly content: ContentScope
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FeedThread {
|
|
19
|
+
readonly threadId: number
|
|
20
|
+
readonly title: string
|
|
21
|
+
readonly slug: string
|
|
22
|
+
readonly forumId: number
|
|
23
|
+
readonly forumTitle: string
|
|
24
|
+
readonly authorUsername: string
|
|
25
|
+
readonly createdAt: Date
|
|
26
|
+
readonly lastPostAt: Date
|
|
27
|
+
readonly replyCount: number
|
|
28
|
+
readonly excerptSource: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FeedPost {
|
|
32
|
+
readonly postId: number
|
|
33
|
+
readonly threadId: number
|
|
34
|
+
readonly threadTitle: string
|
|
35
|
+
readonly threadSlug: string
|
|
36
|
+
readonly authorUsername: string
|
|
37
|
+
readonly createdAt: Date
|
|
38
|
+
readonly messageSource: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SitemapForum {
|
|
42
|
+
readonly forumId: number
|
|
43
|
+
readonly slug: string
|
|
44
|
+
readonly lastPostAt: Date | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SitemapThread {
|
|
48
|
+
readonly threadId: number
|
|
49
|
+
readonly slug: string
|
|
50
|
+
readonly lastPostAt: Date
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const EXCERPT_CHARS = 500
|
|
54
|
+
|
|
55
|
+
export class PostgresFeedRepository {
|
|
56
|
+
constructor(private readonly db: Database) {}
|
|
57
|
+
|
|
58
|
+
async recentThreads(
|
|
59
|
+
limit: number,
|
|
60
|
+
scope: FeedScope,
|
|
61
|
+
forumId?: number,
|
|
62
|
+
): Promise<readonly FeedThread[]> {
|
|
63
|
+
if (audienceIsEmpty(scope)) return []
|
|
64
|
+
|
|
65
|
+
const audience = inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope)
|
|
66
|
+
const forums = forumId === undefined ? audience : sql`t.forum_id = ${forumId} and ${audience}`
|
|
67
|
+
|
|
68
|
+
const rows = resultRows(
|
|
69
|
+
await this.db.execute(sql`
|
|
70
|
+
select t.id, t.title, t.slug, t.forum_id, f.title as forum_title,
|
|
71
|
+
t.author_username, t.created_at, t.last_post_at, t.reply_count,
|
|
72
|
+
left(p.message, ${EXCERPT_CHARS}) as excerpt
|
|
73
|
+
from threads t
|
|
74
|
+
join forums f on f.id = t.forum_id
|
|
75
|
+
/*
|
|
76
|
+
* The opening post, and left-joined *through the scope*: a thread
|
|
77
|
+
* whose first post was removed is still a thread, and it appears with
|
|
78
|
+
* no summary rather than vanishing from the feed.
|
|
79
|
+
*/
|
|
80
|
+
left join posts p
|
|
81
|
+
on p.id = t.first_post_id
|
|
82
|
+
and ${visibleIn(sql`p.visibility`, scope.content)}
|
|
83
|
+
where ${forums}
|
|
84
|
+
and ${visibleIn(sql`t.visibility`, scope.content)}
|
|
85
|
+
order by t.last_post_at desc, t.id desc
|
|
86
|
+
limit ${limit}
|
|
87
|
+
`),
|
|
88
|
+
) as Array<Record<string, unknown>>
|
|
89
|
+
|
|
90
|
+
return rows.map((row) => ({
|
|
91
|
+
threadId: Number(row.id),
|
|
92
|
+
title: String(row.title),
|
|
93
|
+
slug: String(row.slug),
|
|
94
|
+
forumId: Number(row.forum_id),
|
|
95
|
+
forumTitle: String(row.forum_title),
|
|
96
|
+
authorUsername: String(row.author_username),
|
|
97
|
+
createdAt: toDate(row.created_at),
|
|
98
|
+
lastPostAt: toDate(row.last_post_at),
|
|
99
|
+
replyCount: Number(row.reply_count),
|
|
100
|
+
excerptSource: row.excerpt === null ? null : String(row.excerpt),
|
|
101
|
+
}))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async recentPosts(
|
|
105
|
+
threadId: number,
|
|
106
|
+
limit: number,
|
|
107
|
+
scope: FeedScope,
|
|
108
|
+
): Promise<readonly FeedPost[]> {
|
|
109
|
+
if (audienceIsEmpty(scope)) return []
|
|
110
|
+
|
|
111
|
+
const rows = resultRows(
|
|
112
|
+
await this.db.execute(sql`
|
|
113
|
+
select p.id, p.thread_id, t.title as thread_title, t.slug as thread_slug,
|
|
114
|
+
p.author_username, p.created_at, left(p.message, ${EXCERPT_CHARS}) as message
|
|
115
|
+
from posts p
|
|
116
|
+
join threads t on t.id = p.thread_id
|
|
117
|
+
where p.thread_id = ${threadId}
|
|
118
|
+
and ${inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope)}
|
|
119
|
+
and ${visibleIn(sql`t.visibility`, scope.content)}
|
|
120
|
+
and ${visibleIn(sql`p.visibility`, scope.content)}
|
|
121
|
+
order by p.created_at desc, p.id desc
|
|
122
|
+
limit ${limit}
|
|
123
|
+
`),
|
|
124
|
+
) as Array<Record<string, unknown>>
|
|
125
|
+
|
|
126
|
+
return rows.map((row) => ({
|
|
127
|
+
postId: Number(row.id),
|
|
128
|
+
threadId: Number(row.thread_id),
|
|
129
|
+
threadTitle: String(row.thread_title),
|
|
130
|
+
threadSlug: String(row.thread_slug),
|
|
131
|
+
authorUsername: String(row.author_username),
|
|
132
|
+
createdAt: toDate(row.created_at),
|
|
133
|
+
messageSource: String(row.message),
|
|
134
|
+
}))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async sitemapForums(scope: FeedScope): Promise<readonly SitemapForum[]> {
|
|
138
|
+
if (scope.forumIds.length === 0) return []
|
|
139
|
+
|
|
140
|
+
const rows = resultRows(
|
|
141
|
+
await this.db.execute(sql`
|
|
142
|
+
select id, slug, last_post_at
|
|
143
|
+
from forums
|
|
144
|
+
where id in (${sql.join(
|
|
145
|
+
scope.forumIds.map((id) => sql`${id}`),
|
|
146
|
+
sql`, `,
|
|
147
|
+
)})
|
|
148
|
+
and type = 'forum'
|
|
149
|
+
order by id
|
|
150
|
+
`),
|
|
151
|
+
) as Array<Record<string, unknown>>
|
|
152
|
+
|
|
153
|
+
return rows.map((row) => ({
|
|
154
|
+
forumId: Number(row.id),
|
|
155
|
+
slug: String(row.slug),
|
|
156
|
+
lastPostAt: row.last_post_at === null ? null : toDate(row.last_post_at),
|
|
157
|
+
}))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async sitemapThreadCount(scope: FeedScope): Promise<number> {
|
|
161
|
+
if (audienceIsEmpty(scope)) return 0
|
|
162
|
+
|
|
163
|
+
const rows = resultRows(
|
|
164
|
+
await this.db.execute(sql`
|
|
165
|
+
select count(*)::int as n
|
|
166
|
+
from threads t
|
|
167
|
+
where ${inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope)}
|
|
168
|
+
and ${visibleIn(sql`t.visibility`, scope.content)}
|
|
169
|
+
`),
|
|
170
|
+
) as Array<{ n: number }>
|
|
171
|
+
|
|
172
|
+
return rows[0]?.n ?? 0
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async sitemapBoundaryId(skip: number, scope: FeedScope): Promise<number | null> {
|
|
176
|
+
if (skip <= 0) return 0
|
|
177
|
+
if (audienceIsEmpty(scope)) return null
|
|
178
|
+
|
|
179
|
+
const rows = resultRows(
|
|
180
|
+
await this.db.execute(sql`
|
|
181
|
+
select t.id
|
|
182
|
+
from threads t
|
|
183
|
+
where ${inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope)}
|
|
184
|
+
and ${visibleIn(sql`t.visibility`, scope.content)}
|
|
185
|
+
order by t.id
|
|
186
|
+
offset ${skip - 1}
|
|
187
|
+
limit 1
|
|
188
|
+
`),
|
|
189
|
+
) as Array<{ id: number }>
|
|
190
|
+
|
|
191
|
+
return rows[0]?.id ?? null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async sitemapThreads(
|
|
195
|
+
afterId: number,
|
|
196
|
+
limit: number,
|
|
197
|
+
scope: FeedScope,
|
|
198
|
+
): Promise<readonly SitemapThread[]> {
|
|
199
|
+
if (audienceIsEmpty(scope)) return []
|
|
200
|
+
|
|
201
|
+
const rows = resultRows(
|
|
202
|
+
await this.db.execute(sql`
|
|
203
|
+
select t.id, t.slug, t.last_post_at
|
|
204
|
+
from threads t
|
|
205
|
+
where ${inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope)}
|
|
206
|
+
and ${visibleIn(sql`t.visibility`, scope.content)}
|
|
207
|
+
and t.id > ${afterId}
|
|
208
|
+
order by t.id
|
|
209
|
+
limit ${limit}
|
|
210
|
+
`),
|
|
211
|
+
) as Array<Record<string, unknown>>
|
|
212
|
+
|
|
213
|
+
return rows.map((row) => ({
|
|
214
|
+
threadId: Number(row.id),
|
|
215
|
+
slug: String(row.slug),
|
|
216
|
+
lastPostAt: toDate(row.last_post_at),
|
|
217
|
+
}))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { ForumOverride } from '@meith/authorization'
|
|
4
|
+
import { FORUM_PERMISSION_FIELDS, ValidationError } from '@meith/core'
|
|
5
|
+
import { msg } from '@meith/i18n'
|
|
6
|
+
|
|
7
|
+
import type { Database } from './client'
|
|
8
|
+
import { resultRows } from './result-rows'
|
|
9
|
+
import { columnName } from './schema/permission-columns'
|
|
10
|
+
|
|
11
|
+
export interface ForumOptionsInput {
|
|
12
|
+
readonly title: string
|
|
13
|
+
readonly slug: string
|
|
14
|
+
readonly description: string | null
|
|
15
|
+
readonly linkUrl: string | null
|
|
16
|
+
readonly displayOrder: number
|
|
17
|
+
readonly isOpen: boolean
|
|
18
|
+
readonly allowThreads: boolean
|
|
19
|
+
readonly allowReplies: boolean
|
|
20
|
+
readonly allowPolls: boolean
|
|
21
|
+
readonly allowAttachments: boolean
|
|
22
|
+
readonly requiresPrefix: boolean
|
|
23
|
+
readonly moderateNewThreads: boolean
|
|
24
|
+
readonly moderateNewPosts: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const MODERATOR_RIGHTS = [
|
|
28
|
+
'canEditPosts',
|
|
29
|
+
'canSoftDeletePosts',
|
|
30
|
+
'canRestorePosts',
|
|
31
|
+
'canApproveContent',
|
|
32
|
+
'canOpenCloseThreads',
|
|
33
|
+
'canStickThreads',
|
|
34
|
+
'canMoveThreads',
|
|
35
|
+
'canMergeThreads',
|
|
36
|
+
'canSplitThreads',
|
|
37
|
+
] as const
|
|
38
|
+
|
|
39
|
+
export type ModeratorRight = (typeof MODERATOR_RIGHTS)[number]
|
|
40
|
+
|
|
41
|
+
export interface ModeratorAppointmentRow {
|
|
42
|
+
readonly id: number
|
|
43
|
+
readonly forumId: number
|
|
44
|
+
readonly userId: number | null
|
|
45
|
+
readonly groupId: number | null
|
|
46
|
+
readonly subject: string
|
|
47
|
+
readonly cascadeToSubforums: boolean
|
|
48
|
+
readonly rights: Readonly<Record<ModeratorRight, boolean>>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AppointModeratorInput {
|
|
52
|
+
readonly forumId: number
|
|
53
|
+
readonly userId: number | null
|
|
54
|
+
readonly groupId: number | null
|
|
55
|
+
readonly cascadeToSubforums: boolean
|
|
56
|
+
readonly rights: Readonly<Record<ModeratorRight, boolean>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class PostgresForumAdminRepository {
|
|
60
|
+
constructor(private readonly db: Database) {}
|
|
61
|
+
|
|
62
|
+
async listGroups(): Promise<readonly { id: number; title: string }[]> {
|
|
63
|
+
const rows = resultRows(
|
|
64
|
+
await this.db.execute(sql`
|
|
65
|
+
select id, title from usergroups order by display_order, id
|
|
66
|
+
`),
|
|
67
|
+
) as Array<{ id: number; title: string }>
|
|
68
|
+
|
|
69
|
+
return rows.map((row) => ({ id: Number(row.id), title: row.title }))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async readOptions(forumId: number): Promise<ForumOptionsInput | null> {
|
|
73
|
+
const rows = resultRows(
|
|
74
|
+
await this.db.execute(sql`
|
|
75
|
+
select title, slug, description, link_url, display_order, is_open,
|
|
76
|
+
allow_threads, allow_replies, allow_polls, allow_attachments,
|
|
77
|
+
requires_prefix, moderate_new_threads, moderate_new_posts
|
|
78
|
+
from forums where id = ${forumId}
|
|
79
|
+
`),
|
|
80
|
+
) as Array<Record<string, unknown>>
|
|
81
|
+
|
|
82
|
+
const row = rows[0]
|
|
83
|
+
if (row === undefined) return null
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
title: String(row.title),
|
|
87
|
+
slug: String(row.slug),
|
|
88
|
+
description: row.description === null ? null : String(row.description),
|
|
89
|
+
linkUrl: row.link_url === null ? null : String(row.link_url),
|
|
90
|
+
displayOrder: Number(row.display_order),
|
|
91
|
+
isOpen: row.is_open === true,
|
|
92
|
+
allowThreads: row.allow_threads === true,
|
|
93
|
+
allowReplies: row.allow_replies === true,
|
|
94
|
+
allowPolls: row.allow_polls === true,
|
|
95
|
+
allowAttachments: row.allow_attachments === true,
|
|
96
|
+
requiresPrefix: row.requires_prefix === true,
|
|
97
|
+
moderateNewThreads: row.moderate_new_threads === true,
|
|
98
|
+
moderateNewPosts: row.moderate_new_posts === true,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async listModerators(forumId: number): Promise<readonly ModeratorAppointmentRow[]> {
|
|
103
|
+
const rows = resultRows(
|
|
104
|
+
await this.db.execute(sql`
|
|
105
|
+
select m.*, u.username, g.title as group_title
|
|
106
|
+
from forum_moderators m
|
|
107
|
+
left join users u on u.id = m.user_id
|
|
108
|
+
left join usergroups g on g.id = m.group_id
|
|
109
|
+
where m.forum_id = ${forumId}
|
|
110
|
+
order by m.group_id nulls last, u.username_lower
|
|
111
|
+
`),
|
|
112
|
+
) as Array<Record<string, unknown>>
|
|
113
|
+
|
|
114
|
+
return rows.map((row) => {
|
|
115
|
+
const rights: Record<string, boolean> = {}
|
|
116
|
+
for (const right of MODERATOR_RIGHTS) rights[right] = row[columnName(right)] === true
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
id: Number(row.id),
|
|
120
|
+
forumId: Number(row.forum_id),
|
|
121
|
+
userId: row.user_id === null ? null : Number(row.user_id),
|
|
122
|
+
groupId: row.group_id === null ? null : Number(row.group_id),
|
|
123
|
+
subject:
|
|
124
|
+
row.group_title !== null && row.group_title !== undefined
|
|
125
|
+
? `${String(row.group_title)} (group)`
|
|
126
|
+
: row.username !== null && row.username !== undefined
|
|
127
|
+
? String(row.username)
|
|
128
|
+
: 'unknown',
|
|
129
|
+
cascadeToSubforums: row.cascade_to_subforums === true,
|
|
130
|
+
rights: rights as Record<ModeratorRight, boolean>,
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async appoint(input: AppointModeratorInput): Promise<void> {
|
|
136
|
+
const hasUser = input.userId !== null
|
|
137
|
+
const hasGroup = input.groupId !== null
|
|
138
|
+
if (hasUser === hasGroup) {
|
|
139
|
+
throw new ValidationError(msg('error.db.appointment-names-member-group-both'))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const columns = MODERATOR_RIGHTS.map((right) => sql.raw(columnName(right)))
|
|
143
|
+
const literals = MODERATOR_RIGHTS.map((right) => sql`${input.rights[right] === true}`)
|
|
144
|
+
const assignments = MODERATOR_RIGHTS.map(
|
|
145
|
+
(right) => sql`${sql.raw(columnName(right))} = excluded.${sql.raw(columnName(right))}`,
|
|
146
|
+
)
|
|
147
|
+
const conflict = hasUser
|
|
148
|
+
? sql`(forum_id, user_id) where user_id is not null`
|
|
149
|
+
: sql`(forum_id, group_id) where group_id is not null`
|
|
150
|
+
|
|
151
|
+
await this.db.execute(sql`
|
|
152
|
+
insert into forum_moderators
|
|
153
|
+
(forum_id, user_id, group_id, cascade_to_subforums,
|
|
154
|
+
${sql.join(columns, sql`, `)})
|
|
155
|
+
values (${input.forumId}, ${input.userId}, ${input.groupId},
|
|
156
|
+
${input.cascadeToSubforums}, ${sql.join(literals, sql`, `)})
|
|
157
|
+
on conflict ${conflict} do update
|
|
158
|
+
set cascade_to_subforums = excluded.cascade_to_subforums,
|
|
159
|
+
${sql.join(assignments, sql`, `)}
|
|
160
|
+
`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async removeModerator(forumId: number, appointmentId: number): Promise<void> {
|
|
164
|
+
await this.db.execute(sql`
|
|
165
|
+
delete from forum_moderators
|
|
166
|
+
where id = ${appointmentId} and forum_id = ${forumId}
|
|
167
|
+
`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async findMemberByUsername(username: string): Promise<{ id: number; username: string } | null> {
|
|
171
|
+
const rows = resultRows(
|
|
172
|
+
await this.db.execute(sql`
|
|
173
|
+
select id, username from users where username_lower = ${username.trim().toLowerCase()}
|
|
174
|
+
`),
|
|
175
|
+
) as Array<{ id: number; username: string }>
|
|
176
|
+
|
|
177
|
+
const row = rows[0]
|
|
178
|
+
return row === undefined ? null : { id: Number(row.id), username: row.username }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async updateOptions(forumId: number, input: ForumOptionsInput): Promise<void> {
|
|
182
|
+
await this.db.execute(sql`
|
|
183
|
+
update forums
|
|
184
|
+
set title = ${input.title},
|
|
185
|
+
slug = ${input.slug},
|
|
186
|
+
description = ${input.description},
|
|
187
|
+
link_url = ${input.linkUrl},
|
|
188
|
+
display_order = ${input.displayOrder},
|
|
189
|
+
is_open = ${input.isOpen},
|
|
190
|
+
allow_threads = ${input.allowThreads},
|
|
191
|
+
allow_replies = ${input.allowReplies},
|
|
192
|
+
allow_polls = ${input.allowPolls},
|
|
193
|
+
allow_attachments = ${input.allowAttachments},
|
|
194
|
+
requires_prefix = ${input.requiresPrefix},
|
|
195
|
+
moderate_new_threads = ${input.moderateNewThreads},
|
|
196
|
+
moderate_new_posts = ${input.moderateNewPosts}
|
|
197
|
+
where id = ${forumId}
|
|
198
|
+
`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async readOverrides(forumIds: readonly number[]): Promise<readonly ForumOverride[]> {
|
|
202
|
+
if (forumIds.length === 0) return []
|
|
203
|
+
|
|
204
|
+
const rows = resultRows(
|
|
205
|
+
await this.db.execute(sql`
|
|
206
|
+
select * from forum_permissions
|
|
207
|
+
where forum_id in ${sql`(${sql.join(
|
|
208
|
+
forumIds.map((id) => sql`${id}`),
|
|
209
|
+
sql`, `,
|
|
210
|
+
)})`}
|
|
211
|
+
`),
|
|
212
|
+
) as Array<Record<string, unknown>>
|
|
213
|
+
|
|
214
|
+
return rows.map((row) => {
|
|
215
|
+
const overrides: Record<string, boolean | number> = {}
|
|
216
|
+
for (const field of FORUM_PERMISSION_FIELDS) {
|
|
217
|
+
const value = row[columnName(field.key)]
|
|
218
|
+
if (value === null || value === undefined) continue
|
|
219
|
+
overrides[field.key] = field.kind === 'numeric' ? Number(value) : value === true
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
forumId: Number(row.forum_id),
|
|
224
|
+
groupId: Number(row.group_id),
|
|
225
|
+
overrides,
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async saveOverrides(
|
|
231
|
+
forumId: number,
|
|
232
|
+
groupId: number,
|
|
233
|
+
values: Readonly<Record<string, boolean | number | null>>,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
await this.db.transaction(async (tx) => {
|
|
236
|
+
if (FORUM_PERMISSION_FIELDS.every((field) => values[field.key] == null)) {
|
|
237
|
+
await tx.execute(sql`
|
|
238
|
+
delete from forum_permissions
|
|
239
|
+
where forum_id = ${forumId} and group_id = ${groupId}
|
|
240
|
+
`)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const columns = FORUM_PERMISSION_FIELDS.map((field) => sql.raw(columnName(field.key)))
|
|
245
|
+
const literals = FORUM_PERMISSION_FIELDS.map((field) => sql`${values[field.key] ?? null}`)
|
|
246
|
+
const assignments = FORUM_PERMISSION_FIELDS.map(
|
|
247
|
+
(field) =>
|
|
248
|
+
sql`${sql.raw(columnName(field.key))} = excluded.${sql.raw(columnName(field.key))}`,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
await tx.execute(sql`
|
|
252
|
+
insert into forum_permissions (forum_id, group_id, ${sql.join(columns, sql`, `)})
|
|
253
|
+
values (${forumId}, ${groupId}, ${sql.join(literals, sql`, `)})
|
|
254
|
+
on conflict (forum_id, group_id) do update set ${sql.join(assignments, sql`, `)}
|
|
255
|
+
`)
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async descendantIds(forumId: number): Promise<readonly number[]> {
|
|
260
|
+
const rows = resultRows(
|
|
261
|
+
await this.db.execute(sql`
|
|
262
|
+
select d.id
|
|
263
|
+
from forums f
|
|
264
|
+
join forums d on d.path like f.path || '.%'
|
|
265
|
+
where f.id = ${forumId}
|
|
266
|
+
order by d.path
|
|
267
|
+
`),
|
|
268
|
+
) as Array<{ id: number }>
|
|
269
|
+
|
|
270
|
+
return rows.map((row) => Number(row.id))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async copyToDescendants(
|
|
274
|
+
forumId: number,
|
|
275
|
+
descendantIds: readonly number[],
|
|
276
|
+
groupIds: readonly number[],
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
if (descendantIds.length === 0 || groupIds.length === 0) return
|
|
279
|
+
|
|
280
|
+
const targets = sql.join(
|
|
281
|
+
descendantIds.map((id) => sql`${id}`),
|
|
282
|
+
sql`, `,
|
|
283
|
+
)
|
|
284
|
+
const groups = sql.join(
|
|
285
|
+
groupIds.map((id) => sql`${id}`),
|
|
286
|
+
sql`, `,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
await this.db.transaction(async (tx) => {
|
|
290
|
+
await tx.execute(sql`
|
|
291
|
+
delete from forum_permissions
|
|
292
|
+
where forum_id in (${targets}) and group_id in (${groups})
|
|
293
|
+
`)
|
|
294
|
+
|
|
295
|
+
const columns = FORUM_PERMISSION_FIELDS.map((field) => sql.raw(columnName(field.key)))
|
|
296
|
+
await tx.execute(sql`
|
|
297
|
+
insert into forum_permissions (forum_id, group_id, ${sql.join(columns, sql`, `)})
|
|
298
|
+
select target.id, source.group_id, ${sql.join(
|
|
299
|
+
FORUM_PERMISSION_FIELDS.map((field) => sql`source.${sql.raw(columnName(field.key))}`),
|
|
300
|
+
sql`, `,
|
|
301
|
+
)}
|
|
302
|
+
from forum_permissions source
|
|
303
|
+
-- Cast: a bare parameter list is text to the planner, and the
|
|
304
|
+
-- insert then refuses it against an integer column.
|
|
305
|
+
cross join (select unnest(array[${targets}]::int[]) as id) target
|
|
306
|
+
where source.forum_id = ${forumId} and source.group_id in (${groups})
|
|
307
|
+
`)
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
}
|