@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.
Files changed (205) hide show
  1. package/LICENSE.md +165 -0
  2. package/migrations/0000_initial_schema.sql +538 -0
  3. package/migrations/0001_seed_usergroups.sql +111 -0
  4. package/migrations/0002_group_promotions.sql +25 -0
  5. package/migrations/0003_counter_maintenance.sql +34 -0
  6. package/migrations/0004_post_render_cache.sql +7 -0
  7. package/migrations/0005_reports.sql +51 -0
  8. package/migrations/0006_warnings.sql +91 -0
  9. package/migrations/0007_notifications.sql +44 -0
  10. package/migrations/0008_subscriptions.sql +48 -0
  11. package/migrations/0009_member_preferences.sql +13 -0
  12. package/migrations/0010_profile_fields.sql +53 -0
  13. package/migrations/0011_private_messages.sql +63 -0
  14. package/migrations/0012_user_relations.sql +18 -0
  15. package/migrations/0013_reputation.sql +45 -0
  16. package/migrations/0014_signatures.sql +16 -0
  17. package/migrations/0015_admin_sessions.sql +24 -0
  18. package/migrations/0016_attachments.sql +51 -0
  19. package/migrations/0017_avatars.sql +33 -0
  20. package/migrations/0018_mass_mail.sql +15 -0
  21. package/migrations/0019_word_filters.sql +8 -0
  22. package/migrations/0020_searches.sql +15 -0
  23. package/migrations/0021_presence_stats.sql +18 -0
  24. package/migrations/0022_api_tokens_webhooks.sql +58 -0
  25. package/migrations/0023_install_state.sql +6 -0
  26. package/migrations/0024_component_versions.sql +13 -0
  27. package/migrations/0025_import.sql +25 -0
  28. package/migrations/0026_polls.sql +40 -0
  29. package/migrations/0027_drafts.sql +13 -0
  30. package/migrations/0028_board_vocabulary.sql +32 -0
  31. package/migrations/0029_announcements.sql +16 -0
  32. package/migrations/0030_antispam.sql +18 -0
  33. package/migrations/0031_markdown.sql +24 -0
  34. package/migrations/0032_theme_enablement.sql +1 -0
  35. package/migrations/0033_group_identity.sql +5 -0
  36. package/migrations/0034_search_document_version.sql +4 -0
  37. package/migrations/0035_categories_hold_no_threads.sql +1 -0
  38. package/migrations/0036_expiring_group_grants.sql +6 -0
  39. package/migrations/0037_automatic_timezone.sql +3 -0
  40. package/migrations/0038_primary_group_grants.sql +3 -0
  41. package/migrations/0039_moderator_log_forum_scope.sql +1 -0
  42. package/migrations/0040_moderator_restore_grants.sql +1 -0
  43. package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
  44. package/migrations/0042_drop_placebo_delete_others.sql +2 -0
  45. package/migrations/0043_federated_identity.sql +28 -0
  46. package/migrations/0044_second_factor.sql +34 -0
  47. package/migrations/0045_drop_dead_task_due_index.sql +1 -0
  48. package/migrations/0046_member_locale.sql +1 -0
  49. package/migrations/0047_navigation_items.sql +34 -0
  50. package/migrations/0048_web_push.sql +16 -0
  51. package/migrations/0049_plugin_health.sql +7 -0
  52. package/migrations/0050_drop_forum_password_grants.sql +1 -0
  53. package/migrations/0051_glossy_barracuda.sql +1 -0
  54. package/migrations/0052_many_professor_monster.sql +2 -0
  55. package/migrations/0053_strange_maximus.sql +14 -0
  56. package/migrations/0054_legacy_import_sources.sql +25 -0
  57. package/migrations/0055_poll_parity.sql +14 -0
  58. package/migrations/0056_marketplace_catalog.sql +11 -0
  59. package/migrations/meta/0000_snapshot.json +4412 -0
  60. package/migrations/meta/0006_snapshot.json +5411 -0
  61. package/migrations/meta/0007_snapshot.json +5634 -0
  62. package/migrations/meta/0008_snapshot.json +5779 -0
  63. package/migrations/meta/0009_snapshot.json +5816 -0
  64. package/migrations/meta/0010_snapshot.json +6117 -0
  65. package/migrations/meta/0011_snapshot.json +6406 -0
  66. package/migrations/meta/0012_snapshot.json +6517 -0
  67. package/migrations/meta/0013_snapshot.json +6722 -0
  68. package/migrations/meta/0014_snapshot.json +6777 -0
  69. package/migrations/meta/0015_snapshot.json +6914 -0
  70. package/migrations/meta/0016_snapshot.json +7192 -0
  71. package/migrations/meta/0017_snapshot.json +7248 -0
  72. package/migrations/meta/0018_snapshot.json +7355 -0
  73. package/migrations/meta/0019_snapshot.json +7419 -0
  74. package/migrations/meta/0020_snapshot.json +7562 -0
  75. package/migrations/meta/0021_snapshot.json +7653 -0
  76. package/migrations/meta/0031_snapshot.json +8162 -0
  77. package/migrations/meta/0032_snapshot.json +8169 -0
  78. package/migrations/meta/0033_snapshot.json +8193 -0
  79. package/migrations/meta/0034_snapshot.json +8221 -0
  80. package/migrations/meta/0036_snapshot.json +8262 -0
  81. package/migrations/meta/0037_snapshot.json +8262 -0
  82. package/migrations/meta/0038_snapshot.json +8293 -0
  83. package/migrations/meta/0039_snapshot.json +8308 -0
  84. package/migrations/meta/0041_snapshot.json +8287 -0
  85. package/migrations/meta/0042_snapshot.json +8274 -0
  86. package/migrations/meta/0043_snapshot.json +8521 -0
  87. package/migrations/meta/0044_snapshot.json +8814 -0
  88. package/migrations/meta/0045_snapshot.json +8797 -0
  89. package/migrations/meta/0046_snapshot.json +8804 -0
  90. package/migrations/meta/0047_snapshot.json +9018 -0
  91. package/migrations/meta/0048_snapshot.json +9145 -0
  92. package/migrations/meta/0049_snapshot.json +9190 -0
  93. package/migrations/meta/0050_snapshot.json +9126 -0
  94. package/migrations/meta/0051_snapshot.json +9132 -0
  95. package/migrations/meta/0052_snapshot.json +9148 -0
  96. package/migrations/meta/0053_snapshot.json +9268 -0
  97. package/migrations/meta/0054_snapshot.json +9378 -0
  98. package/migrations/meta/0056_snapshot.json +9447 -0
  99. package/migrations/meta/_journal.json +405 -0
  100. package/package.json +64 -0
  101. package/src/account-repos.ts +580 -0
  102. package/src/actor-builder.ts +130 -0
  103. package/src/admin-repo.ts +85 -0
  104. package/src/admin-session-repo.ts +205 -0
  105. package/src/admin-undo-repo.ts +128 -0
  106. package/src/announcement-repo.ts +176 -0
  107. package/src/antispam-repo.ts +145 -0
  108. package/src/api-repo.ts +276 -0
  109. package/src/attachment-admin-repo.ts +161 -0
  110. package/src/attachment-repo.ts +240 -0
  111. package/src/authorization-source.ts +138 -0
  112. package/src/avatar-repo.ts +185 -0
  113. package/src/ban-repos.ts +146 -0
  114. package/src/client.ts +85 -0
  115. package/src/content-admin-repo.ts +298 -0
  116. package/src/content-counters.ts +152 -0
  117. package/src/counter-recount.ts +263 -0
  118. package/src/cursor.ts +16 -0
  119. package/src/denormalised-username.ts +34 -0
  120. package/src/discovery-repo.ts +140 -0
  121. package/src/draft-repo.ts +59 -0
  122. package/src/feed-repo.ts +219 -0
  123. package/src/forum-admin-repo.ts +310 -0
  124. package/src/forum-repo.ts +206 -0
  125. package/src/group-admin-repo.ts +256 -0
  126. package/src/group-identity-repo.ts +84 -0
  127. package/src/identity-link-repo.ts +145 -0
  128. package/src/import-repo.ts +123 -0
  129. package/src/import-sink.ts +1377 -0
  130. package/src/index.ts +316 -0
  131. package/src/inline-moderation.ts +344 -0
  132. package/src/install-repo.ts +67 -0
  133. package/src/latest-repo.ts +115 -0
  134. package/src/maintenance-repo.ts +54 -0
  135. package/src/marketplace-repo.ts +111 -0
  136. package/src/member-profile-repo.ts +82 -0
  137. package/src/member-settings-repo.ts +160 -0
  138. package/src/message-repo.ts +384 -0
  139. package/src/migrate-run.ts +18 -0
  140. package/src/migrate.ts +129 -0
  141. package/src/modcp-repo.ts +253 -0
  142. package/src/moderation-queue.ts +279 -0
  143. package/src/navigation-repo.ts +288 -0
  144. package/src/notification-repo.ts +402 -0
  145. package/src/orphan-keys.ts +19 -0
  146. package/src/outbox-repo.ts +78 -0
  147. package/src/permission-version.ts +24 -0
  148. package/src/permissions-map.ts +52 -0
  149. package/src/pglite.fixture.ts +87 -0
  150. package/src/plugin-data.ts +88 -0
  151. package/src/plugin-grants.ts +369 -0
  152. package/src/plugin-health-repo.ts +80 -0
  153. package/src/plugin-purge-repo.ts +91 -0
  154. package/src/plugin-users.ts +39 -0
  155. package/src/poll-repo.ts +334 -0
  156. package/src/post-repo.ts +261 -0
  157. package/src/post-writes.ts +227 -0
  158. package/src/presence-repo.ts +220 -0
  159. package/src/profile-field-repo.ts +182 -0
  160. package/src/promotion-repo.ts +152 -0
  161. package/src/read-state-repo.ts +81 -0
  162. package/src/relation-repo.ts +113 -0
  163. package/src/render-backfill.ts +110 -0
  164. package/src/render-signature.ts +33 -0
  165. package/src/report-repo.ts +349 -0
  166. package/src/reputation-repo.ts +295 -0
  167. package/src/result-rows.ts +13 -0
  168. package/src/row-values.ts +7 -0
  169. package/src/schema/content.ts +451 -0
  170. package/src/schema/identity.ts +699 -0
  171. package/src/schema/index.ts +10 -0
  172. package/src/schema/messages.ts +76 -0
  173. package/src/schema/permission-columns.ts +34 -0
  174. package/src/schema/platform.ts +376 -0
  175. package/src/schema/structure.ts +164 -0
  176. package/src/search-repo.ts +359 -0
  177. package/src/search-store.ts +113 -0
  178. package/src/seed-groups.ts +11 -0
  179. package/src/settings-repo.ts +32 -0
  180. package/src/signature-repo.ts +102 -0
  181. package/src/sql-lists.ts +17 -0
  182. package/src/staff-groups.ts +50 -0
  183. package/src/stats-repo.ts +151 -0
  184. package/src/subscription-repo.ts +350 -0
  185. package/src/system-health-repo.ts +130 -0
  186. package/src/task-repo.ts +104 -0
  187. package/src/theme-admin-repo.ts +172 -0
  188. package/src/theme-repo.ts +43 -0
  189. package/src/thread-audience.ts +45 -0
  190. package/src/thread-counters.ts +107 -0
  191. package/src/thread-repo.ts +255 -0
  192. package/src/thread-surgery.ts +282 -0
  193. package/src/thread-tools.ts +311 -0
  194. package/src/thread-views.ts +41 -0
  195. package/src/thread-writes.ts +356 -0
  196. package/src/two-factor-repo.ts +235 -0
  197. package/src/upgrade-repo.ts +66 -0
  198. package/src/user-admin-repo.ts +367 -0
  199. package/src/user-bulk-repo.ts +397 -0
  200. package/src/user-merge-map.ts +111 -0
  201. package/src/user-merge-repo.ts +304 -0
  202. package/src/visibility-counters.ts +190 -0
  203. package/src/visibility.ts +17 -0
  204. package/src/vocabulary-repo.ts +54 -0
  205. package/src/warning-repo.ts +328 -0
@@ -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
+ }