@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,235 @@
1
+ import { and, count, desc, eq, isNull, lt, sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ AuthEventKind,
5
+ AuthEventRecord,
6
+ AuthEventRepository,
7
+ NewAuthEvent,
8
+ RecoveryCodeRepository,
9
+ TwoFactorRecord,
10
+ TwoFactorRepository,
11
+ } from '@meith/accounts'
12
+
13
+ import type { Database } from './client'
14
+ import { resultRows } from './result-rows'
15
+ import { authEvents, recoveryCodes, userTwoFactor } from './schema'
16
+
17
+ const TWO_FACTOR_COLUMNS = {
18
+ userId: userTwoFactor.userId,
19
+ sealedSecret: userTwoFactor.sealedSecret,
20
+ confirmedAt: userTwoFactor.confirmedAt,
21
+ lastStep: userTwoFactor.lastStep,
22
+ createdAt: userTwoFactor.createdAt,
23
+ } as const
24
+
25
+ export class PostgresTwoFactorRepository implements TwoFactorRepository {
26
+ constructor(private readonly db: Database) {}
27
+
28
+ async find(userId: number): Promise<TwoFactorRecord | null> {
29
+ const rows = await this.db
30
+ .select(TWO_FACTOR_COLUMNS)
31
+ .from(userTwoFactor)
32
+ .where(eq(userTwoFactor.userId, userId))
33
+ .limit(1)
34
+ return rows[0] ?? null
35
+ }
36
+
37
+ async startEnrolment(input: {
38
+ userId: number
39
+ sealedSecret: string
40
+ now: Date
41
+ }): Promise<TwoFactorRecord> {
42
+ const rows = await this.db
43
+ .insert(userTwoFactor)
44
+ .values({
45
+ userId: input.userId,
46
+ sealedSecret: input.sealedSecret,
47
+ confirmedAt: null,
48
+ lastStep: null,
49
+ createdAt: input.now,
50
+ })
51
+ .onConflictDoUpdate({
52
+ target: userTwoFactor.userId,
53
+ set: {
54
+ sealedSecret: input.sealedSecret,
55
+ confirmedAt: null,
56
+ lastStep: null,
57
+ createdAt: input.now,
58
+ },
59
+ setWhere: isNull(userTwoFactor.confirmedAt),
60
+ })
61
+ .returning(TWO_FACTOR_COLUMNS)
62
+
63
+ const record = rows[0] ?? (await this.find(input.userId))
64
+ if (record === null || record === undefined) {
65
+ throw new Error('the two-factor enrolment vanished between insert and read')
66
+ }
67
+ return record
68
+ }
69
+
70
+ async confirm(userId: number, step: number, now: Date): Promise<boolean> {
71
+ const rows = await this.db
72
+ .update(userTwoFactor)
73
+ .set({ confirmedAt: now, lastStep: step })
74
+ .where(and(eq(userTwoFactor.userId, userId), isNull(userTwoFactor.confirmedAt)))
75
+ .returning({ userId: userTwoFactor.userId })
76
+ return rows.length > 0
77
+ }
78
+
79
+ async spendStep(userId: number, step: number): Promise<boolean> {
80
+ const rows = await this.db
81
+ .update(userTwoFactor)
82
+ .set({ lastStep: step })
83
+ .where(
84
+ and(
85
+ eq(userTwoFactor.userId, userId),
86
+ sql`(${userTwoFactor.lastStep} is null or ${userTwoFactor.lastStep} < ${step})`,
87
+ ),
88
+ )
89
+ .returning({ userId: userTwoFactor.userId })
90
+ return rows.length > 0
91
+ }
92
+
93
+ async remove(userId: number): Promise<boolean> {
94
+ const rows = await this.db
95
+ .delete(userTwoFactor)
96
+ .where(eq(userTwoFactor.userId, userId))
97
+ .returning({ userId: userTwoFactor.userId })
98
+ return rows.length > 0
99
+ }
100
+ }
101
+
102
+ export class PostgresRecoveryCodeRepository implements RecoveryCodeRepository {
103
+ constructor(private readonly db: Database) {}
104
+
105
+ async replaceAll(userId: number, hashes: readonly string[], now: Date): Promise<void> {
106
+ await this.db.transaction(async (tx) => {
107
+ await tx.delete(recoveryCodes).where(eq(recoveryCodes.userId, userId))
108
+
109
+ if (hashes.length === 0) return
110
+
111
+ await tx
112
+ .insert(recoveryCodes)
113
+ .values(hashes.map((codeHash) => ({ userId, codeHash, createdAt: now })))
114
+ })
115
+ }
116
+
117
+ async spend(userId: number, hash: string, now: Date): Promise<boolean> {
118
+ const rows = await this.db
119
+ .update(recoveryCodes)
120
+ .set({ usedAt: now })
121
+ .where(
122
+ and(
123
+ eq(recoveryCodes.userId, userId),
124
+ eq(recoveryCodes.codeHash, hash),
125
+ isNull(recoveryCodes.usedAt),
126
+ ),
127
+ )
128
+ .returning({ id: recoveryCodes.id })
129
+ return rows.length > 0
130
+ }
131
+
132
+ async countUnused(userId: number): Promise<number> {
133
+ const rows = await this.db
134
+ .select({ total: count() })
135
+ .from(recoveryCodes)
136
+ .where(and(eq(recoveryCodes.userId, userId), isNull(recoveryCodes.usedAt)))
137
+ return Number(rows[0]?.total ?? 0)
138
+ }
139
+
140
+ async removeAll(userId: number): Promise<void> {
141
+ await this.db.delete(recoveryCodes).where(eq(recoveryCodes.userId, userId))
142
+ }
143
+ }
144
+
145
+ const EVENT_COLUMNS = {
146
+ id: authEvents.id,
147
+ userId: authEvents.userId,
148
+ kind: authEvents.kind,
149
+ ipPrefix: authEvents.ipPrefix,
150
+ userAgent: authEvents.userAgent,
151
+ detail: authEvents.detail,
152
+ at: authEvents.at,
153
+ } as const
154
+
155
+ function toEvent(row: {
156
+ id: number
157
+ userId: number | null
158
+ kind: string
159
+ ipPrefix: string | null
160
+ userAgent: string | null
161
+ detail: unknown
162
+ at: Date
163
+ }): AuthEventRecord {
164
+ return {
165
+ id: row.id,
166
+ userId: row.userId,
167
+ kind: row.kind as AuthEventKind,
168
+ ipPrefix: row.ipPrefix,
169
+ userAgent: row.userAgent,
170
+ detail:
171
+ typeof row.detail === 'object' && row.detail !== null
172
+ ? (row.detail as Record<string, unknown>)
173
+ : {},
174
+ at: row.at,
175
+ }
176
+ }
177
+
178
+ export class PostgresAuthEventRepository implements AuthEventRepository {
179
+ constructor(private readonly db: Database) {}
180
+
181
+ async record(event: NewAuthEvent): Promise<void> {
182
+ await this.db.insert(authEvents).values({
183
+ userId: event.userId,
184
+ kind: event.kind,
185
+ ipPrefix: event.ipPrefix ?? null,
186
+ userAgent: event.userAgent ?? null,
187
+ detail: event.detail ?? {},
188
+ at: event.at,
189
+ })
190
+ }
191
+
192
+ async listForUser(userId: number, limit: number): Promise<readonly AuthEventRecord[]> {
193
+ const rows = await this.db
194
+ .select(EVENT_COLUMNS)
195
+ .from(authEvents)
196
+ .where(eq(authEvents.userId, userId))
197
+ .orderBy(desc(authEvents.id))
198
+ .limit(limit)
199
+ return rows.map(toEvent)
200
+ }
201
+
202
+ async listRecent(input: {
203
+ limit: number
204
+ before?: number | undefined
205
+ kind?: AuthEventKind | undefined
206
+ }): Promise<readonly AuthEventRecord[]> {
207
+ const filters = [
208
+ input.before === undefined ? undefined : lt(authEvents.id, input.before),
209
+ input.kind === undefined ? undefined : eq(authEvents.kind, input.kind),
210
+ ].filter((filter) => filter !== undefined)
211
+
212
+ const rows = await this.db
213
+ .select(EVENT_COLUMNS)
214
+ .from(authEvents)
215
+ .where(filters.length === 0 ? undefined : and(...filters))
216
+ .orderBy(desc(authEvents.id))
217
+ .limit(input.limit)
218
+ return rows.map(toEvent)
219
+ }
220
+
221
+ async pruneBefore(cutoff: Date, limit = 5000): Promise<number> {
222
+ const result = await this.db.execute(sql`
223
+ delete from ${authEvents}
224
+ where id in (
225
+ select id from ${authEvents}
226
+ where at < ${cutoff}
227
+ order by id
228
+ limit ${limit}
229
+ )
230
+ returning id
231
+ `)
232
+
233
+ return resultRows(result).length
234
+ }
235
+ }
@@ -0,0 +1,66 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+
5
+ export async function readVersion(db: Database, component: string): Promise<string | null> {
6
+ const rows = (await db.execute(sql`
7
+ select version from component_versions where component = ${component}
8
+ `)) as unknown as { version: string }[]
9
+ return rows[0]?.version ?? null
10
+ }
11
+
12
+ export async function readVersions(db: Database): Promise<Readonly<Record<string, string>>> {
13
+ const rows = (await db.execute(sql`
14
+ select component, version from component_versions order by component
15
+ `)) as unknown as { component: string; version: string }[]
16
+
17
+ return Object.fromEntries(rows.map((row) => [row.component, row.version]))
18
+ }
19
+
20
+ export async function recordVersion(
21
+ db: Database,
22
+ component: string,
23
+ version: string,
24
+ ): Promise<void> {
25
+ await db.execute(sql`
26
+ insert into component_versions (component, version, updated_at)
27
+ values (${component}, ${version}, now())
28
+ on conflict (component) do update set version = excluded.version, updated_at = now()
29
+ `)
30
+ }
31
+
32
+ export async function appliedPluginMigrations(
33
+ db: Database,
34
+ pluginKey: string,
35
+ ): Promise<readonly string[]> {
36
+ const rows = (await db.execute(sql`
37
+ select migration_id from plugin_migrations
38
+ where plugin_key = ${pluginKey}
39
+ order by migration_id
40
+ `)) as unknown as { migration_id: string }[]
41
+
42
+ return rows.map((row) => row.migration_id)
43
+ }
44
+
45
+ export async function applyPluginMigration(
46
+ db: Database,
47
+ pluginKey: string,
48
+ migrationId: string,
49
+ statements: readonly string[],
50
+ ): Promise<boolean> {
51
+ return db.transaction(async (tx) => {
52
+ const claimed = (await tx.execute(sql`
53
+ insert into plugin_migrations (plugin_key, migration_id)
54
+ values (${pluginKey}, ${migrationId})
55
+ on conflict (plugin_key, migration_id) do nothing
56
+ returning migration_id
57
+ `)) as unknown as unknown[]
58
+
59
+ if (claimed.length === 0) return false
60
+
61
+ for (const statement of statements) {
62
+ await tx.execute(sql.raw(statement))
63
+ }
64
+ return true
65
+ })
66
+ }
@@ -0,0 +1,367 @@
1
+ import { type SQL, sql } from 'drizzle-orm'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import { msg } from '@meith/i18n'
5
+
6
+ import type { Database } from './client'
7
+ import { rewriteDenormalisedUsernames } from './denormalised-username'
8
+ import { withPermissionVersionBump } from './permission-version'
9
+ import { resultRows } from './result-rows'
10
+ import { toDate, toNullableDate } from './row-values'
11
+
12
+ export type AccountState = 'active' | 'awaiting_activation' | 'banned'
13
+
14
+ const STATES: readonly AccountState[] = ['active', 'awaiting_activation', 'banned']
15
+
16
+ export const BANNED_PREDICATE: SQL = sql`(u.state = 'banned' or exists (
17
+ select 1 from bans b where b.user_id = u.id and b.lifted_at is null
18
+ ))`
19
+
20
+ export interface UserSearchFilter {
21
+ readonly username?: string | undefined
22
+ readonly email?: string | undefined
23
+ readonly ipPrefix?: string | undefined
24
+ readonly primaryGroupId?: number | undefined
25
+ readonly state?: AccountState | undefined
26
+ readonly registeredBefore?: Date | undefined
27
+ readonly registeredAfter?: Date | undefined
28
+ readonly minPostCount?: number | undefined
29
+ readonly maxPostCount?: number | undefined
30
+ readonly includeDeleted?: boolean | undefined
31
+ readonly afterUserId?: number | undefined
32
+ readonly offset?: number | undefined
33
+ readonly limit: number
34
+ }
35
+
36
+ export interface UserSearchRow {
37
+ readonly id: number
38
+ readonly username: string
39
+ readonly email: string
40
+ readonly state: AccountState
41
+ readonly isBanned: boolean
42
+ readonly primaryGroupId: number
43
+ readonly primaryGroupTitle: string
44
+ readonly postCount: number
45
+ readonly reputation: number
46
+ readonly warningPoints: number
47
+ readonly registrationIpPrefix: string | null
48
+ readonly lastIpPrefix: string | null
49
+ readonly lastActiveAt: Date | null
50
+ readonly createdAt: Date
51
+ readonly deletedAt: Date | null
52
+ }
53
+
54
+ export interface UserSearchPage {
55
+ readonly rows: readonly UserSearchRow[]
56
+ readonly nextCursor: number | null
57
+ /** How many accounts the filter matches, whichever page was asked for. */
58
+ readonly total: number
59
+ }
60
+
61
+ export interface UserDetail extends UserSearchRow {
62
+ readonly displayGroupId: number | null
63
+ readonly emailVerifiedAt: Date | null
64
+ readonly passwordChangedAt: Date | null
65
+ readonly threadCount: number
66
+ readonly legacyId: number | null
67
+ }
68
+
69
+ export interface UserAccountInput {
70
+ readonly username: string
71
+ readonly email: string
72
+ readonly primaryGroupId: number
73
+ readonly displayGroupId: number | null
74
+ }
75
+
76
+ export function likeFragment(value: string): string {
77
+ return value.replace(/[\\%_]/g, (character) => `\\${character}`)
78
+ }
79
+
80
+ function fold(value: string): string {
81
+ return value.trim().toLowerCase()
82
+ }
83
+
84
+ function assertState(state: string): AccountState {
85
+ if (!STATES.includes(state as AccountState)) {
86
+ throw new ValidationError(msg('error.db.such-account-state'))
87
+ }
88
+ return state as AccountState
89
+ }
90
+
91
+ function toSearchRow(row: Record<string, unknown>): UserSearchRow {
92
+ return {
93
+ id: Number(row.id),
94
+ username: String(row.username),
95
+ email: String(row.email),
96
+ state: assertState(String(row.state)),
97
+ isBanned: row.is_banned === true || row.is_banned === 't',
98
+ primaryGroupId: Number(row.primary_group_id),
99
+ primaryGroupTitle: String(row.primary_group_title ?? ''),
100
+ postCount: Number(row.post_count),
101
+ reputation: Number(row.reputation),
102
+ warningPoints: Number(row.warning_points),
103
+ registrationIpPrefix:
104
+ row.registration_ip_prefix === null ? null : String(row.registration_ip_prefix),
105
+ lastIpPrefix: row.last_ip_prefix === null ? null : String(row.last_ip_prefix),
106
+ lastActiveAt: toNullableDate(row.last_active_at),
107
+ createdAt: toDate(row.created_at),
108
+ deletedAt: toNullableDate(row.deleted_at),
109
+ }
110
+ }
111
+
112
+ export class PostgresUserAdminRepository {
113
+ constructor(private readonly db: Database) {}
114
+
115
+ async search(filter: UserSearchFilter): Promise<UserSearchPage> {
116
+ const conditions: SQL[] = []
117
+
118
+ if (filter.afterUserId !== undefined && filter.afterUserId > 0) {
119
+ conditions.push(sql`u.id > ${filter.afterUserId}`)
120
+ }
121
+
122
+ if (filter.username !== undefined && filter.username.trim() !== '') {
123
+ conditions.push(
124
+ sql`u.username_lower like ${`%${likeFragment(fold(filter.username))}%`} escape '\\'`,
125
+ )
126
+ }
127
+ if (filter.email !== undefined && filter.email.trim() !== '') {
128
+ conditions.push(
129
+ sql`u.email_lower like ${`%${likeFragment(fold(filter.email))}%`} escape '\\'`,
130
+ )
131
+ }
132
+ if (filter.ipPrefix !== undefined && filter.ipPrefix.trim() !== '') {
133
+ const pattern = `${likeFragment(filter.ipPrefix.trim())}%`
134
+ conditions.push(
135
+ sql`(u.registration_ip_prefix like ${pattern} escape '\\'
136
+ or u.last_ip_prefix like ${pattern} escape '\\')`,
137
+ )
138
+ }
139
+ if (filter.primaryGroupId !== undefined) {
140
+ conditions.push(sql`u.primary_group_id = ${filter.primaryGroupId}`)
141
+ }
142
+ if (filter.state !== undefined) {
143
+ const state = assertState(filter.state)
144
+ conditions.push(
145
+ state === 'banned' ? BANNED_PREDICATE : sql`u.state = ${state} and not ${BANNED_PREDICATE}`,
146
+ )
147
+ }
148
+ if (filter.registeredAfter !== undefined) {
149
+ conditions.push(sql`u.created_at >= ${filter.registeredAfter}`)
150
+ }
151
+ if (filter.registeredBefore !== undefined) {
152
+ conditions.push(sql`u.created_at < ${filter.registeredBefore}`)
153
+ }
154
+ if (filter.minPostCount !== undefined) {
155
+ conditions.push(sql`u.post_count >= ${filter.minPostCount}`)
156
+ }
157
+ if (filter.maxPostCount !== undefined) {
158
+ conditions.push(sql`u.post_count <= ${filter.maxPostCount}`)
159
+ }
160
+ if (filter.includeDeleted !== true) {
161
+ conditions.push(sql`u.deleted_at is null`)
162
+ }
163
+
164
+ const where = conditions.length === 0 ? sql`true` : sql.join(conditions, sql` and `)
165
+ const offset = filter.offset ?? 0
166
+
167
+ const [listed, counted] = await Promise.all([
168
+ this.db.execute(sql`
169
+ select u.id, u.username, u.email, u.state, u.primary_group_id,
170
+ g.title as primary_group_title,
171
+ u.post_count, u.reputation, u.warning_points,
172
+ u.registration_ip_prefix, u.last_ip_prefix,
173
+ u.last_active_at, u.created_at, u.deleted_at,
174
+ ${BANNED_PREDICATE} as is_banned
175
+ from users u
176
+ join usergroups g on g.id = u.primary_group_id
177
+ where ${where}
178
+ order by u.id
179
+ limit ${filter.limit} offset ${offset}
180
+ `),
181
+ this.db.execute(sql`
182
+ select count(*)::int as total
183
+ from users u
184
+ where ${where}
185
+ `),
186
+ ])
187
+
188
+ const mapped = (resultRows(listed) as Array<Record<string, unknown>>).map(toSearchRow)
189
+ const total = Number((resultRows(counted) as Array<Record<string, unknown>>)[0]?.total ?? 0)
190
+
191
+ return {
192
+ rows: mapped,
193
+ nextCursor: mapped.length < filter.limit ? null : (mapped[mapped.length - 1]?.id ?? null),
194
+ total,
195
+ }
196
+ }
197
+
198
+ async readDetail(userId: number): Promise<UserDetail | null> {
199
+ const rows = resultRows(
200
+ await this.db.execute(sql`
201
+ select u.*, g.title as primary_group_title,
202
+ ${BANNED_PREDICATE} as is_banned
203
+ from users u
204
+ join usergroups g on g.id = u.primary_group_id
205
+ where u.id = ${userId}
206
+ `),
207
+ ) as Array<Record<string, unknown>>
208
+
209
+ const row = rows[0]
210
+ if (row === undefined) return null
211
+
212
+ return {
213
+ ...toSearchRow(row),
214
+ displayGroupId: row.display_group_id === null ? null : Number(row.display_group_id),
215
+ emailVerifiedAt: toNullableDate(row.email_verified_at),
216
+ passwordChangedAt: toNullableDate(row.password_changed_at),
217
+ threadCount: Number(row.thread_count),
218
+ legacyId: row.legacy_id === null ? null : Number(row.legacy_id),
219
+ }
220
+ }
221
+
222
+ async updateAccount(userId: number, input: UserAccountInput): Promise<void> {
223
+ const username = input.username.trim()
224
+ const email = input.email.trim()
225
+ if (username === '') throw new ValidationError(msg('error.db.member-needs-username'))
226
+ if (email === '') throw new ValidationError(msg('error.db.member-needs-email-address'))
227
+
228
+ const primaryGroupId = input.primaryGroupId
229
+
230
+ await withPermissionVersionBump(this.db, async (tx) => {
231
+ const named = resultRows(
232
+ await tx.execute(sql`select username from users where id = ${userId}`),
233
+ ) as Array<{ username: string }>
234
+
235
+ const previous = named[0]?.username
236
+
237
+ const rows = resultRows(
238
+ await tx.execute(sql`
239
+ update users
240
+ set username = ${username},
241
+ username_lower = ${fold(username)},
242
+ email = ${email},
243
+ email_lower = ${fold(email)},
244
+ primary_group_id = ${primaryGroupId},
245
+ display_group_id = ${input.displayGroupId},
246
+ updated_at = now()
247
+ where id = ${userId}
248
+ returning id
249
+ `),
250
+ ) as Array<{ id: number }>
251
+
252
+ if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-member'))
253
+
254
+ if (previous !== username) {
255
+ await rewriteDenormalisedUsernames(tx, userId, username)
256
+ }
257
+ })
258
+ }
259
+
260
+ async setState(userId: number, state: AccountState): Promise<void> {
261
+ if (state === 'banned') {
262
+ throw new ValidationError(msg('error.db.bans-issued-through-ban-screen'))
263
+ }
264
+
265
+ await withPermissionVersionBump(this.db, async (tx) => {
266
+ const rows = resultRows(
267
+ await tx.execute(sql`
268
+ update users u set state = ${assertState(state)}, updated_at = now()
269
+ where u.id = ${userId} and not ${BANNED_PREDICATE}
270
+ returning u.id
271
+ `),
272
+ ) as Array<{ id: number }>
273
+
274
+ if (rows[0] === undefined) {
275
+ throw new ValidationError(msg('error.db.such-member-member-under-ban'))
276
+ }
277
+ })
278
+ }
279
+
280
+ async sharingIpPrefix(
281
+ prefix: string,
282
+ excludeUserId: number,
283
+ limit = 50,
284
+ ): Promise<readonly UserSearchRow[]> {
285
+ if (prefix.trim() === '') return []
286
+
287
+ const pattern = `${likeFragment(prefix.trim())}%`
288
+ const rows = resultRows(
289
+ await this.db.execute(sql`
290
+ select u.id, u.username, u.email, u.state, u.primary_group_id,
291
+ g.title as primary_group_title,
292
+ u.post_count, u.reputation, u.warning_points,
293
+ u.registration_ip_prefix, u.last_ip_prefix,
294
+ u.last_active_at, u.created_at, u.deleted_at
295
+ from users u
296
+ join usergroups g on g.id = u.primary_group_id
297
+ where u.id <> ${excludeUserId}
298
+ and (u.registration_ip_prefix like ${pattern} escape '\\'
299
+ or u.last_ip_prefix like ${pattern} escape '\\')
300
+ order by u.id
301
+ limit ${limit}
302
+ `),
303
+ ) as Array<Record<string, unknown>>
304
+
305
+ return rows.map(toSearchRow)
306
+ }
307
+
308
+ async readSecondaryGroups(userId: number): Promise<readonly number[]> {
309
+ const rows = resultRows(
310
+ await this.db.execute(sql`
311
+ select group_id from user_group_memberships
312
+ where user_id = ${userId}
313
+ order by group_id
314
+ `),
315
+ ) as Array<{ group_id: number }>
316
+
317
+ return rows.map((row) => Number(row.group_id))
318
+ }
319
+
320
+ async setSecondaryGroups(
321
+ userId: number,
322
+ groupIds: readonly number[],
323
+ grantedByUserId: number | null,
324
+ ): Promise<void> {
325
+ await withPermissionVersionBump(this.db, async (tx) => {
326
+ const rows = resultRows(
327
+ await tx.execute(sql`select primary_group_id from users where id = ${userId}`),
328
+ ) as Array<Record<string, unknown>>
329
+
330
+ if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-member'))
331
+ const primary = Number(rows[0].primary_group_id)
332
+
333
+ const wanted = new Set([...groupIds].filter((id) => id !== primary))
334
+
335
+ const held = resultRows(
336
+ await tx.execute(sql`
337
+ select group_id from user_group_memberships where user_id = ${userId}
338
+ `),
339
+ ) as Array<{ group_id: number }>
340
+ const existing = new Set(held.map((row) => Number(row.group_id)))
341
+
342
+ for (const groupId of existing) {
343
+ if (wanted.has(groupId) || groupId === primary) continue
344
+ await tx.execute(sql`
345
+ delete from user_group_memberships
346
+ where user_id = ${userId} and group_id = ${groupId}
347
+ `)
348
+ }
349
+
350
+ for (const groupId of wanted) {
351
+ if (existing.has(groupId)) continue
352
+ await tx.execute(sql`
353
+ insert into user_group_memberships (user_id, group_id, granted_by_user_id)
354
+ values (${userId}, ${groupId}, ${grantedByUserId})
355
+ `)
356
+ }
357
+ })
358
+ }
359
+
360
+ async listGroups(): Promise<readonly { readonly id: number; readonly title: string }[]> {
361
+ const rows = resultRows(
362
+ await this.db.execute(sql`select id, title from usergroups order by display_order, id`),
363
+ ) as Array<Record<string, unknown>>
364
+
365
+ return rows.map((row) => ({ id: Number(row.id), title: String(row.title) }))
366
+ }
367
+ }