@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,85 @@
1
+ import { eq, or, sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { cacheVersions, usergroups, users } from './schema'
5
+
6
+ export interface UserSummary {
7
+ readonly id: number
8
+ readonly username: string
9
+ readonly primaryGroupId: number | null
10
+ }
11
+
12
+ export interface GroupSummary {
13
+ readonly id: number
14
+ readonly key: string
15
+ readonly title: string
16
+ }
17
+
18
+ function numericOr(reference: string): number | null {
19
+ const value = Number(reference)
20
+ return Number.isInteger(value) && reference.trim() !== '' ? value : null
21
+ }
22
+
23
+ export class PostgresAdminRepository {
24
+ constructor(private readonly db: Database) {}
25
+
26
+ async findUser(reference: string, usernameLower: string): Promise<UserSummary | null> {
27
+ const id = numericOr(reference)
28
+ const rows = await this.db
29
+ .select({
30
+ id: users.id,
31
+ username: users.username,
32
+ primaryGroupId: users.primaryGroupId,
33
+ })
34
+ .from(users)
35
+ .where(
36
+ id === null
37
+ ? eq(users.usernameLower, usernameLower)
38
+ : or(eq(users.id, id), eq(users.usernameLower, usernameLower)),
39
+ )
40
+ .limit(1)
41
+
42
+ return rows[0] ?? null
43
+ }
44
+
45
+ async findGroup(reference: string): Promise<GroupSummary | null> {
46
+ const id = numericOr(reference)
47
+ const rows = await this.db
48
+ .select({ id: usergroups.id, key: usergroups.key, title: usergroups.title })
49
+ .from(usergroups)
50
+ .where(
51
+ id === null
52
+ ? eq(usergroups.key, reference)
53
+ : or(eq(usergroups.id, id), eq(usergroups.key, reference)),
54
+ )
55
+ .limit(1)
56
+
57
+ return rows[0] ?? null
58
+ }
59
+
60
+ async listGroupKeys(): Promise<string[]> {
61
+ const rows = await this.db.select({ key: usergroups.key }).from(usergroups)
62
+ return rows.map((r) => r.key)
63
+ }
64
+
65
+ async setPrimaryGroup(userId: number, groupId: number): Promise<void> {
66
+ await this.db.transaction(async (tx) => {
67
+ await tx
68
+ .update(users)
69
+ .set({ primaryGroupId: groupId, displayGroupId: groupId })
70
+ .where(eq(users.id, userId))
71
+
72
+ await tx
73
+ .insert(cacheVersions)
74
+ .values({ key: 'permissions', version: 1 })
75
+ .onConflictDoUpdate({
76
+ target: cacheVersions.key,
77
+ set: { version: sql`${cacheVersions.version} + 1`, bumpedAt: new Date() },
78
+ })
79
+ })
80
+ }
81
+
82
+ async registeredGroupId(): Promise<number | null> {
83
+ return (await this.findGroup('registered'))?.id ?? null
84
+ }
85
+ }
@@ -0,0 +1,205 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ AdminLogRepository,
5
+ AdminLogRow,
6
+ AdminSessionRecord,
7
+ AdminSessionRepository,
8
+ } from '@meith/admin'
9
+
10
+ import type { Database } from './client'
11
+ import { resultRows } from './result-rows'
12
+ import { toDate, toNullableDate } from './row-values'
13
+
14
+ interface RawSession {
15
+ id: number
16
+ user_id: number
17
+ ip_prefix: string | null
18
+ authenticated_at: string | Date
19
+ last_seen_at: string | Date
20
+ expires_at: string | Date
21
+ revoked_at: string | Date | null
22
+ created_at: string | Date
23
+ }
24
+
25
+ function toSession(row: RawSession): AdminSessionRecord {
26
+ return {
27
+ id: Number(row.id),
28
+ userId: Number(row.user_id),
29
+ ipPrefix: row.ip_prefix,
30
+ authenticatedAt: toDate(row.authenticated_at),
31
+ lastSeenAt: toDate(row.last_seen_at),
32
+ expiresAt: toDate(row.expires_at),
33
+ revokedAt: toNullableDate(row.revoked_at),
34
+ createdAt: toDate(row.created_at),
35
+ }
36
+ }
37
+
38
+ const SESSION_COLUMNS = sql`
39
+ id, user_id, ip_prefix, authenticated_at, last_seen_at,
40
+ expires_at, revoked_at, created_at
41
+ `
42
+
43
+ export class PostgresAdminSessionRepository implements AdminSessionRepository {
44
+ constructor(private readonly db: Database) {}
45
+
46
+ async create(input: {
47
+ readonly userId: number
48
+ readonly tokenHash: string
49
+ readonly ipPrefix: string | null
50
+ readonly expiresAt: Date
51
+ readonly at: Date
52
+ }): Promise<AdminSessionRecord> {
53
+ const rows = resultRows(
54
+ await this.db.execute(sql`
55
+ insert into admin_sessions
56
+ (user_id, token_hash, ip_prefix, authenticated_at, last_seen_at,
57
+ expires_at, created_at)
58
+ values (${input.userId}, ${input.tokenHash}, ${input.ipPrefix}, ${input.at},
59
+ ${input.at}, ${input.expiresAt}, ${input.at})
60
+ returning ${SESSION_COLUMNS}
61
+ `),
62
+ ) as RawSession[]
63
+
64
+ if (rows[0] === undefined) throw new Error('Admin session insert returned no row')
65
+ return toSession(rows[0])
66
+ }
67
+
68
+ async findLive(tokenHash: string, now: Date): Promise<AdminSessionRecord | null> {
69
+ const rows = resultRows(
70
+ await this.db.execute(sql`
71
+ select ${SESSION_COLUMNS}
72
+ from admin_sessions
73
+ where token_hash = ${tokenHash}
74
+ and revoked_at is null
75
+ and expires_at > ${now}
76
+ `),
77
+ ) as RawSession[]
78
+
79
+ return rows[0] === undefined ? null : toSession(rows[0])
80
+ }
81
+
82
+ async touch(sessionId: number, now: Date, expiresAt: Date, windowSeconds: number): Promise<void> {
83
+ const cutoff = new Date(now.getTime() - windowSeconds * 1000)
84
+ await this.db.execute(sql`
85
+ update admin_sessions
86
+ set last_seen_at = ${now}, expires_at = ${expiresAt}
87
+ where id = ${sessionId}
88
+ and revoked_at is null
89
+ and last_seen_at < ${cutoff}
90
+ `)
91
+ }
92
+
93
+ async markReauthenticated(sessionId: number, at: Date): Promise<void> {
94
+ await this.db.execute(sql`
95
+ update admin_sessions
96
+ set authenticated_at = ${at}, last_seen_at = ${at}
97
+ where id = ${sessionId} and revoked_at is null
98
+ `)
99
+ }
100
+
101
+ async revoke(sessionId: number, at: Date): Promise<void> {
102
+ await this.db.execute(sql`
103
+ update admin_sessions set revoked_at = ${at}
104
+ where id = ${sessionId} and revoked_at is null
105
+ `)
106
+ }
107
+
108
+ async revokeAllForUser(userId: number, at: Date): Promise<void> {
109
+ await this.db.execute(sql`
110
+ update admin_sessions set revoked_at = ${at}
111
+ where user_id = ${userId} and revoked_at is null
112
+ `)
113
+ }
114
+ }
115
+
116
+ export const ACTION_FILTER_LIMIT = 1_000
117
+
118
+ export class PostgresAdminLogRepository implements AdminLogRepository {
119
+ constructor(private readonly db: Database) {}
120
+
121
+ async record(input: {
122
+ readonly userId: number | null
123
+ readonly action: string
124
+ readonly detail: Readonly<Record<string, unknown>>
125
+ readonly ipPrefix: string | null
126
+ readonly at: Date
127
+ }): Promise<void> {
128
+ await this.db.execute(sql`
129
+ insert into admin_log (user_id, action, detail, ip_prefix, created_at)
130
+ values (${input.userId}, ${input.action}, ${JSON.stringify(input.detail)}::jsonb,
131
+ ${input.ipPrefix}, ${input.at})
132
+ `)
133
+ }
134
+
135
+ async list(input: {
136
+ readonly limit: number
137
+ readonly offset?: number | undefined
138
+ readonly before?: number | undefined
139
+ readonly action?: string | undefined
140
+ }): Promise<readonly AdminLogRow[]> {
141
+ const before = input.before === undefined ? sql`` : sql`and l.id < ${input.before}`
142
+ const action =
143
+ input.action === undefined || input.action === ''
144
+ ? sql``
145
+ : sql`and l.action = ${input.action}`
146
+
147
+ const rows = resultRows(
148
+ await this.db.execute(sql`
149
+ select l.id, l.user_id, l.action, l.detail, l.ip_prefix, l.created_at,
150
+ u.username
151
+ from admin_log l
152
+ left join users u on u.id = l.user_id
153
+ where true ${before} ${action}
154
+ order by l.id desc
155
+ limit ${input.limit} offset ${input.offset ?? 0}
156
+ `),
157
+ ) as Array<{
158
+ id: number
159
+ user_id: number | null
160
+ action: string
161
+ detail: unknown
162
+ ip_prefix: string | null
163
+ created_at: string | Date
164
+ username: string | null
165
+ }>
166
+
167
+ return rows.map((row) => ({
168
+ id: Number(row.id),
169
+ userId: row.user_id === null ? null : Number(row.user_id),
170
+ username: row.username,
171
+ action: row.action,
172
+ detail:
173
+ typeof row.detail === 'object' && row.detail !== null && !Array.isArray(row.detail)
174
+ ? (row.detail as Record<string, unknown>)
175
+ : {},
176
+ ipPrefix: row.ip_prefix,
177
+ createdAt: toDate(row.created_at),
178
+ }))
179
+ }
180
+
181
+ async count(input: { readonly action?: string | undefined }): Promise<number> {
182
+ const action =
183
+ input.action === undefined || input.action === ''
184
+ ? sql``
185
+ : sql`and l.action = ${input.action}`
186
+
187
+ const rows = resultRows(
188
+ await this.db.execute(sql`
189
+ select count(*)::int as total from admin_log l where true ${action}
190
+ `),
191
+ ) as Array<{ total: number }>
192
+
193
+ return Number(rows[0]?.total ?? 0)
194
+ }
195
+
196
+ async actions(limit = ACTION_FILTER_LIMIT): Promise<readonly string[]> {
197
+ const rows = resultRows(
198
+ await this.db.execute(sql`
199
+ select distinct action from admin_log order by action limit ${limit}
200
+ `),
201
+ ) as Array<{ action: string }>
202
+
203
+ return rows.map((row) => row.action)
204
+ }
205
+ }
@@ -0,0 +1,128 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { resultRows } from './result-rows'
5
+ import { toDate, toNullableDate } from './row-values'
6
+
7
+ export const ADMIN_UNDO_MAX_SNAPSHOT_BYTES = 64 * 1024
8
+
9
+ export interface AdminUndoOperation {
10
+ readonly id: number
11
+ readonly actorUserId: number
12
+ readonly operation: string
13
+ readonly snapshot: Readonly<Record<string, unknown>>
14
+ readonly createdAt: Date
15
+ readonly expiresAt: Date
16
+ readonly consumedAt: Date | null
17
+ }
18
+
19
+ interface RawUndoOperation {
20
+ id: number
21
+ actor_user_id: number
22
+ operation: string
23
+ snapshot: unknown
24
+ created_at: string | Date
25
+ expires_at: string | Date
26
+ consumed_at: string | Date | null
27
+ }
28
+
29
+ function mapped(row: RawUndoOperation): AdminUndoOperation {
30
+ const snapshot =
31
+ typeof row.snapshot === 'object' && row.snapshot !== null && !Array.isArray(row.snapshot)
32
+ ? (row.snapshot as Record<string, unknown>)
33
+ : {}
34
+
35
+ return {
36
+ id: Number(row.id),
37
+ actorUserId: Number(row.actor_user_id),
38
+ operation: row.operation,
39
+ snapshot,
40
+ createdAt: toDate(row.created_at),
41
+ expiresAt: toDate(row.expires_at),
42
+ consumedAt: toNullableDate(row.consumed_at),
43
+ }
44
+ }
45
+
46
+ const COLUMNS = sql`
47
+ id, actor_user_id, operation, snapshot, created_at, expires_at, consumed_at
48
+ `
49
+
50
+ export class PostgresAdminUndoRepository {
51
+ constructor(private readonly db: Database) {}
52
+
53
+ async create(input: {
54
+ readonly tokenHash: string
55
+ readonly actorUserId: number
56
+ readonly operation: string
57
+ readonly snapshot: Readonly<Record<string, unknown>>
58
+ readonly createdAt: Date
59
+ readonly expiresAt: Date
60
+ }): Promise<void> {
61
+ const encoded = JSON.stringify(input.snapshot)
62
+ if (Buffer.byteLength(encoded, 'utf8') > ADMIN_UNDO_MAX_SNAPSHOT_BYTES) {
63
+ throw new RangeError('admin_undo_snapshot_limit')
64
+ }
65
+
66
+ await this.db.execute(sql`
67
+ insert into admin_undo_operations
68
+ (token_hash, actor_user_id, operation, snapshot, created_at, expires_at)
69
+ values (${input.tokenHash}, ${input.actorUserId}, ${input.operation},
70
+ ${encoded}::jsonb, ${input.createdAt}, ${input.expiresAt})
71
+ `)
72
+ }
73
+
74
+ async findLive(input: {
75
+ readonly tokenHash: string
76
+ readonly actorUserId: number
77
+ readonly now: Date
78
+ }): Promise<AdminUndoOperation | null> {
79
+ const rows = resultRows(
80
+ await this.db.execute(sql`
81
+ select ${COLUMNS}
82
+ from admin_undo_operations
83
+ where token_hash = ${input.tokenHash}
84
+ and actor_user_id = ${input.actorUserId}
85
+ and consumed_at is null
86
+ and expires_at > ${input.now}
87
+ `),
88
+ ) as RawUndoOperation[]
89
+
90
+ return rows[0] === undefined ? null : mapped(rows[0])
91
+ }
92
+
93
+ async claim(input: {
94
+ readonly tokenHash: string
95
+ readonly actorUserId: number
96
+ readonly now: Date
97
+ }): Promise<AdminUndoOperation | null> {
98
+ const rows = resultRows(
99
+ await this.db.execute(sql`
100
+ update admin_undo_operations
101
+ set consumed_at = ${input.now}
102
+ where token_hash = ${input.tokenHash}
103
+ and actor_user_id = ${input.actorUserId}
104
+ and consumed_at is null
105
+ and expires_at > ${input.now}
106
+ returning ${COLUMNS}
107
+ `),
108
+ ) as RawUndoOperation[]
109
+
110
+ return rows[0] === undefined ? null : mapped(rows[0])
111
+ }
112
+
113
+ async removeExpired(now: Date, limit = 500): Promise<number> {
114
+ const rows = resultRows(
115
+ await this.db.execute(sql`
116
+ delete from admin_undo_operations
117
+ where id in (
118
+ select id from admin_undo_operations
119
+ where expires_at <= ${now}
120
+ order by expires_at
121
+ limit ${limit}
122
+ )
123
+ returning id
124
+ `),
125
+ )
126
+ return rows.length
127
+ }
128
+ }
@@ -0,0 +1,176 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import { msg } from '@meith/i18n'
5
+ import { BodyFormat, sourceAsMarkdown } from '@meith/markdown'
6
+
7
+ import type { Database } from './client'
8
+ import { resultRows } from './result-rows'
9
+
10
+ export interface AnnouncementRow {
11
+ readonly id: number
12
+ readonly forumId: number | null
13
+ readonly forumTitle: string | null
14
+ readonly forumSlug: string | null
15
+ readonly title: string
16
+ readonly message: string
17
+ readonly authorUserId: number | null
18
+ readonly authorUsername: string
19
+ readonly startsAt: Date
20
+ readonly endsAt: Date | null
21
+ readonly enabled: boolean
22
+ readonly createdAt: Date
23
+ }
24
+
25
+ export interface AnnouncementInput {
26
+ readonly forumId: number | null
27
+ readonly title: string
28
+ readonly message: string
29
+ readonly startsAt: Date
30
+ readonly endsAt: Date | null
31
+ readonly enabled: boolean
32
+ }
33
+
34
+ function toRow(row: Record<string, unknown>): AnnouncementRow {
35
+ return {
36
+ id: Number(row.id),
37
+ forumId: row.forum_id === null ? null : Number(row.forum_id),
38
+ forumTitle:
39
+ row.forum_title === null || row.forum_title === undefined ? null : String(row.forum_title),
40
+ forumSlug:
41
+ row.forum_slug === null || row.forum_slug === undefined ? null : String(row.forum_slug),
42
+ title: String(row.title),
43
+ message: sourceAsMarkdown(String(row.message), Number(row.body_format ?? BodyFormat.Markdown)),
44
+ authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
45
+ authorUsername: String(row.author_username),
46
+ startsAt: new Date(String(row.starts_at)),
47
+ endsAt: row.ends_at === null ? null : new Date(String(row.ends_at)),
48
+ enabled: row.enabled === true,
49
+ createdAt: new Date(String(row.created_at)),
50
+ }
51
+ }
52
+
53
+ export class PostgresAnnouncementRepository {
54
+ constructor(private readonly db: Database) {}
55
+
56
+ async live(input: {
57
+ readonly now: Date
58
+ readonly visibleForumIds: readonly number[]
59
+ readonly scope?: number | null
60
+ }): Promise<readonly AnnouncementRow[]> {
61
+ const scope = input.scope ?? null
62
+
63
+ const visible = sql`${sql.raw('ARRAY[')}${
64
+ input.visibleForumIds.length === 0
65
+ ? sql.raw('')
66
+ : sql.join(
67
+ input.visibleForumIds.map((id) => sql`${id}`),
68
+ sql`, `,
69
+ )
70
+ }${sql.raw(']::int[]')}`
71
+
72
+ const rows = resultRows(
73
+ await this.db.execute(sql`
74
+ select a.id, a.forum_id, a.title, a.message, a.body_format, a.author_user_id,
75
+ a.author_username, a.starts_at, a.ends_at, a.enabled, a.created_at,
76
+ f.title as forum_title, f.slug as forum_slug
77
+ from announcements a
78
+ left join forums f on f.id = a.forum_id
79
+ where a.enabled
80
+ and a.starts_at <= ${input.now}
81
+ and (a.ends_at is null or a.ends_at > ${input.now})
82
+ and (
83
+ a.forum_id is null
84
+ or (a.forum_id = ${scope} and a.forum_id = any(${visible}))
85
+ )
86
+ order by a.starts_at desc, a.id desc
87
+ `),
88
+ ) as Array<Record<string, unknown>>
89
+
90
+ return rows.map(toRow)
91
+ }
92
+
93
+ async list(): Promise<readonly AnnouncementRow[]> {
94
+ const rows = resultRows(
95
+ await this.db.execute(sql`
96
+ select a.id, a.forum_id, a.title, a.message, a.body_format, a.author_user_id,
97
+ a.author_username, a.starts_at, a.ends_at, a.enabled, a.created_at,
98
+ f.title as forum_title, f.slug as forum_slug
99
+ from announcements a
100
+ left join forums f on f.id = a.forum_id
101
+ order by a.starts_at desc, a.id desc
102
+ `),
103
+ ) as Array<Record<string, unknown>>
104
+
105
+ return rows.map(toRow)
106
+ }
107
+
108
+ async find(id: number): Promise<AnnouncementRow | null> {
109
+ const rows = resultRows(
110
+ await this.db.execute(sql`
111
+ select a.id, a.forum_id, a.title, a.message, a.body_format, a.author_user_id,
112
+ a.author_username, a.starts_at, a.ends_at, a.enabled, a.created_at,
113
+ f.title as forum_title, f.slug as forum_slug
114
+ from announcements a
115
+ left join forums f on f.id = a.forum_id
116
+ where a.id = ${id}
117
+ `),
118
+ ) as Array<Record<string, unknown>>
119
+
120
+ const row = rows[0]
121
+ return row === undefined ? null : toRow(row)
122
+ }
123
+
124
+ async create(
125
+ input: AnnouncementInput & { readonly authorUserId: number | null },
126
+ ): Promise<number> {
127
+ assertValid(input)
128
+
129
+ const rows = resultRows(
130
+ await this.db.execute(sql`
131
+ insert into announcements (forum_id, title, message, body_format,
132
+ author_user_id, author_username, starts_at,
133
+ ends_at, enabled)
134
+ values (${input.forumId}, ${input.title}, ${input.message},
135
+ ${BodyFormat.Markdown}, ${input.authorUserId},
136
+ coalesce((select username from users where id = ${input.authorUserId}), ''),
137
+ ${input.startsAt}, ${input.endsAt}, ${input.enabled})
138
+ returning id
139
+ `),
140
+ ) as Array<{ id: number }>
141
+
142
+ return Number(rows[0]?.id)
143
+ }
144
+
145
+ async update(id: number, input: AnnouncementInput): Promise<void> {
146
+ assertValid(input)
147
+
148
+ const rows = resultRows(
149
+ await this.db.execute(sql`
150
+ update announcements
151
+ set forum_id = ${input.forumId}, title = ${input.title},
152
+ message = ${input.message}, body_format = ${BodyFormat.Markdown},
153
+ starts_at = ${input.startsAt},
154
+ ends_at = ${input.endsAt}, enabled = ${input.enabled}
155
+ where id = ${id}
156
+ returning id
157
+ `),
158
+ ) as Array<{ id: number }>
159
+
160
+ if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-announcement'))
161
+ }
162
+
163
+ async delete(id: number): Promise<void> {
164
+ await this.db.execute(sql`delete from announcements where id = ${id}`)
165
+ }
166
+ }
167
+
168
+ function assertValid(input: AnnouncementInput): void {
169
+ if (input.title.trim() === '') throw new ValidationError(msg('error.db.announcement-needs-title'))
170
+ if (input.message.trim() === '') {
171
+ throw new ValidationError(msg('error.db.announcement-needs-something'))
172
+ }
173
+ if (input.endsAt !== null && input.endsAt <= input.startsAt) {
174
+ throw new ValidationError(msg('error.db.announcement-finish-before-starts'))
175
+ }
176
+ }