@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,288 @@
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 { resultRows } from './result-rows'
8
+ import { NAVIGATION_AUDIENCES } from './schema/platform'
9
+ import { idList } from './sql-lists'
10
+
11
+ export type NavigationAudience = (typeof NAVIGATION_AUDIENCES)[number]
12
+
13
+ export interface NavigationDropTarget {
14
+ readonly parentId: number | null
15
+ readonly afterId: number | null
16
+ }
17
+
18
+ export interface NavigationItemRow {
19
+ readonly id: number
20
+ readonly key: string | null
21
+ readonly parentId: number | null
22
+ readonly label: string
23
+ readonly href: string
24
+ readonly displayOrder: number
25
+ readonly audience: NavigationAudience
26
+ readonly newTab: boolean
27
+ readonly enabled: boolean
28
+ readonly visibleToGroups: readonly number[]
29
+ }
30
+
31
+ /** A navigation item a plugin declares, already namespaced by the host. */
32
+ export interface PluginNavigationRow {
33
+ readonly key: string
34
+ readonly href: string
35
+ readonly audience: NavigationAudience
36
+ /** The key of the sibling row this one is created under, if any. */
37
+ readonly parentKey: string | null
38
+ }
39
+
40
+ export interface NavigationItemInput {
41
+ readonly parentId: number | null
42
+ readonly label: string
43
+ readonly href: string
44
+ readonly audience: NavigationAudience
45
+ readonly newTab: boolean
46
+ readonly enabled: boolean
47
+ readonly visibleToGroups: readonly number[]
48
+ }
49
+
50
+ function audienceOf(value: unknown): NavigationAudience {
51
+ const text = String(value)
52
+ const found = NAVIGATION_AUDIENCES.find((audience) => audience === text)
53
+ return found ?? 'all'
54
+ }
55
+
56
+ function groupsOf(value: unknown): readonly number[] {
57
+ if (!Array.isArray(value)) return []
58
+ return value.map((entry) => Number(entry)).filter((id) => Number.isSafeInteger(id))
59
+ }
60
+
61
+ function toRow(row: Record<string, unknown>): NavigationItemRow {
62
+ return {
63
+ id: Number(row.id),
64
+ key: row.key === null || row.key === undefined ? null : String(row.key),
65
+ parentId: row.parent_id === null || row.parent_id === undefined ? null : Number(row.parent_id),
66
+ label: String(row.label),
67
+ href: String(row.href),
68
+ displayOrder: Number(row.display_order),
69
+ audience: audienceOf(row.audience),
70
+ newTab: row.new_tab === true,
71
+ enabled: row.enabled === true,
72
+ visibleToGroups: groupsOf(row.group_ids),
73
+ }
74
+ }
75
+
76
+ export class PostgresNavigationRepository {
77
+ constructor(private readonly db: Database) {}
78
+
79
+ async list(): Promise<readonly NavigationItemRow[]> {
80
+ const rows = resultRows(
81
+ await this.db.execute(sql`
82
+ select n.id, n.key, n.parent_id, n.label, n.href, n.display_order, n.audience,
83
+ n.new_tab, n.enabled,
84
+ coalesce(
85
+ (select array_agg(g.group_id order by g.group_id)
86
+ from navigation_item_groups g
87
+ where g.item_id = n.id),
88
+ '{}'::int[]
89
+ ) as group_ids
90
+ from navigation_items n
91
+ order by n.display_order, n.id
92
+ `),
93
+ ) as Array<Record<string, unknown>>
94
+
95
+ return rows.map(toRow)
96
+ }
97
+
98
+ /**
99
+ * Bring the plugin-owned rows into line with what the installed plugins ask
100
+ * for. An item's row is created once and then belongs to the operator: its
101
+ * label, order, nesting, audience and visibility survive every redeploy,
102
+ * because only the address a plugin owns is refreshed here. A row whose
103
+ * plugin is gone goes with it.
104
+ */
105
+ async syncPluginItems(declared: readonly PluginNavigationRow[]): Promise<{
106
+ readonly added: readonly string[]
107
+ readonly removed: readonly string[]
108
+ }> {
109
+ const existing = (await this.list()).filter((row) => row.key?.startsWith('plugin.') === true)
110
+ const wanted = new Map(declared.map((item) => [item.key, item]))
111
+
112
+ const removed = existing.filter((row) => !wanted.has(row.key as string))
113
+ for (const row of removed) await this.delete(row.id)
114
+
115
+ const added: string[] = []
116
+
117
+ const topLevelIds = new Map(
118
+ existing
119
+ .filter((row) => row.parentId === null)
120
+ .map((row) => [row.key as string, row.id] as const),
121
+ )
122
+ const inCreationOrder = [...declared].sort(
123
+ (a, b) => Number(a.parentKey !== null) - Number(b.parentKey !== null),
124
+ )
125
+
126
+ for (const item of inCreationOrder) {
127
+ const current = existing.find((row) => row.key === item.key)
128
+
129
+ if (current === undefined) {
130
+ const parentId = item.parentKey === null ? null : (topLevelIds.get(item.parentKey) ?? null)
131
+ const rows = resultRows(
132
+ await this.db.execute(sql`
133
+ insert into navigation_items (key, parent_id, label, href, display_order, audience)
134
+ values (${item.key}, ${parentId}, '', ${item.href},
135
+ ${this.lastPlace(parentId)}, ${item.audience})
136
+ returning id
137
+ `),
138
+ ) as Array<{ id: number }>
139
+ if (parentId === null) topLevelIds.set(item.key, Number(rows[0]?.id))
140
+ added.push(item.key)
141
+ continue
142
+ }
143
+
144
+ if (current.href !== item.href) {
145
+ await this.db.execute(sql`
146
+ update navigation_items set href = ${item.href} where id = ${current.id}
147
+ `)
148
+ }
149
+ }
150
+
151
+ return { added, removed: removed.map((row) => row.key as string) }
152
+ }
153
+
154
+ async create(input: NavigationItemInput): Promise<number> {
155
+ assertValid(input, null)
156
+ await this.assertParentUsable(input.parentId, null)
157
+
158
+ const rows = resultRows(
159
+ await this.db.execute(sql`
160
+ insert into navigation_items (key, parent_id, label, href, display_order,
161
+ audience, new_tab, enabled)
162
+ values (null, ${input.parentId}, ${input.label}, ${input.href},
163
+ ${this.lastPlace(input.parentId)},
164
+ ${input.audience}, ${input.newTab}, ${input.enabled})
165
+ returning id
166
+ `),
167
+ ) as Array<{ id: number }>
168
+
169
+ const id = Number(rows[0]?.id)
170
+ await this.setGroups(id, input.visibleToGroups)
171
+ return id
172
+ }
173
+
174
+ async update(id: number, input: NavigationItemInput): Promise<void> {
175
+ const current = await this.find(id)
176
+ if (current === null) throw new ValidationError(msg('error.db.such-navigation-item'))
177
+
178
+ assertValid(input, current.key)
179
+ await this.assertParentUsable(input.parentId, id)
180
+
181
+ const moved = input.parentId !== current.parentId
182
+
183
+ await this.db.execute(sql`
184
+ update navigation_items
185
+ set parent_id = ${input.parentId}, label = ${input.label}, href = ${input.href},
186
+ audience = ${input.audience}, new_tab = ${input.newTab},
187
+ enabled = ${input.enabled}
188
+ ${moved ? sql`, display_order = ${this.lastPlace(input.parentId)}` : sql``}
189
+ where id = ${id}
190
+ `)
191
+
192
+ await this.setGroups(id, input.visibleToGroups)
193
+ }
194
+
195
+ async arrange(id: number, target: NavigationDropTarget): Promise<void> {
196
+ const rows = await this.list()
197
+
198
+ const row = rows.find((entry) => entry.id === id)
199
+ if (row === undefined) throw new ValidationError(msg('error.db.such-navigation-item'))
200
+
201
+ if (target.parentId !== null) {
202
+ const parent = rows.find((entry) => entry.id === target.parentId)
203
+ if (parent === undefined || parent.id === id || parent.parentId !== null) {
204
+ throw new ValidationError(msg('error.db.navigation-item-parent-top-level'))
205
+ }
206
+ if (rows.some((entry) => entry.parentId === id)) {
207
+ throw new ValidationError(msg('error.db.navigation-item-with-items-under'))
208
+ }
209
+ }
210
+
211
+ const siblings = rows
212
+ .filter((entry) => entry.parentId === target.parentId && entry.id !== id)
213
+ .map((entry) => entry.id)
214
+
215
+ const at = target.afterId === null ? 0 : siblings.indexOf(target.afterId) + 1 || siblings.length
216
+ siblings.splice(at, 0, id)
217
+
218
+ await this.db.transaction(async (tx) => {
219
+ await tx.execute(sql`
220
+ update navigation_items set parent_id = ${target.parentId} where id = ${id}
221
+ `)
222
+
223
+ for (const [place, sibling] of siblings.entries()) {
224
+ await tx.execute(sql`
225
+ update navigation_items set display_order = ${place * 10} where id = ${sibling}
226
+ `)
227
+ }
228
+ })
229
+ }
230
+
231
+ async delete(id: number): Promise<void> {
232
+ await this.db.execute(sql`delete from navigation_items where id = ${id}`)
233
+ }
234
+
235
+ private lastPlace(parentId: number | null): SQL {
236
+ return sql`(select coalesce(max(display_order), -10) + 10 from navigation_items
237
+ where parent_id is not distinct from ${parentId})`
238
+ }
239
+
240
+ private async assertParentUsable(parentId: number | null, id: number | null): Promise<void> {
241
+ if (parentId === null) return
242
+
243
+ const rows = await this.list()
244
+
245
+ const parent = rows.find((row) => row.id === parentId)
246
+ if (parent === undefined || parent.id === id || parent.parentId !== null) {
247
+ throw new ValidationError(msg('error.db.navigation-item-parent-top-level'))
248
+ }
249
+ if (id !== null && rows.some((row) => row.parentId === id)) {
250
+ throw new ValidationError(msg('error.db.navigation-item-with-items-under'))
251
+ }
252
+ }
253
+
254
+ private async find(id: number): Promise<NavigationItemRow | null> {
255
+ const rows = resultRows(
256
+ await this.db.execute(sql`
257
+ select id, key, parent_id, label, href, display_order, audience, new_tab, enabled,
258
+ '{}'::int[] as group_ids
259
+ from navigation_items
260
+ where id = ${id}
261
+ `),
262
+ ) as Array<Record<string, unknown>>
263
+
264
+ const row = rows[0]
265
+ return row === undefined ? null : toRow(row)
266
+ }
267
+
268
+ private async setGroups(id: number, groupIds: readonly number[]): Promise<void> {
269
+ await this.db.execute(sql`delete from navigation_item_groups where item_id = ${id}`)
270
+
271
+ const wanted = [...new Set(groupIds)]
272
+ if (wanted.length === 0) return
273
+
274
+ await this.db.execute(sql`
275
+ insert into navigation_item_groups (item_id, group_id)
276
+ select ${id}, id from usergroups where id in ${idList(wanted)}
277
+ `)
278
+ }
279
+ }
280
+
281
+ function assertValid(input: NavigationItemInput, key: string | null): void {
282
+ if (key === null && input.label.trim() === '') {
283
+ throw new ValidationError(msg('error.db.navigation-item-needs-label'))
284
+ }
285
+ if (input.href.trim() === '') {
286
+ throw new ValidationError(msg('error.db.navigation-item-needs-address'))
287
+ }
288
+ }
@@ -0,0 +1,402 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ DeliverableNotification,
5
+ NotificationChannel,
6
+ NotificationChannelPreference,
7
+ NotificationData,
8
+ NotificationPage,
9
+ NotificationRecord,
10
+ NotificationRepository,
11
+ PushSubscriptionRecord,
12
+ RaiseInput,
13
+ RaiseResult,
14
+ SavePushSubscriptionInput,
15
+ } from '@meith/notifications'
16
+ import { notificationKind } from '@meith/notifications'
17
+
18
+ import type { Database } from './client'
19
+ import { decodeCursor, encodeCursor } from './cursor'
20
+ import { resultRows } from './result-rows'
21
+ import { toDate, toNullableDate } from './row-values'
22
+
23
+ interface RawNotification {
24
+ id: number
25
+ user_id: number
26
+ kind: string
27
+ data: unknown
28
+ href: string | null
29
+ occurrences: number
30
+ created_at: string | Date
31
+ updated_at: string | Date
32
+ read_at: string | Date | null
33
+ }
34
+
35
+ function toData(value: unknown): NotificationData {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
37
+ ? (value as NotificationData)
38
+ : {}
39
+ }
40
+
41
+ function toChannel(value: boolean | null): boolean | null {
42
+ return value === null ? null : value === true
43
+ }
44
+
45
+ function toRecord(row: RawNotification): NotificationRecord {
46
+ return {
47
+ id: Number(row.id),
48
+ userId: Number(row.user_id),
49
+ kind: row.kind,
50
+ data: toData(row.data),
51
+ href: row.href,
52
+ occurrences: Number(row.occurrences),
53
+ createdAt: toDate(row.created_at),
54
+ updatedAt: toDate(row.updated_at),
55
+ readAt: toNullableDate(row.read_at),
56
+ }
57
+ }
58
+
59
+ const SELECT_NOTIFICATION = sql`
60
+ select n.id, n.user_id, n.kind, n.data, n.href, n.occurrences,
61
+ n.created_at, n.updated_at, n.read_at
62
+ from notifications n
63
+ `
64
+
65
+ export class PostgresNotificationRepository implements NotificationRepository {
66
+ constructor(private readonly db: Database) {}
67
+
68
+ async raise(input: RaiseInput): Promise<RaiseResult> {
69
+ return this.db.transaction(async (tx) => {
70
+ const payload = JSON.stringify(input.data)
71
+
72
+ const rows = resultRows(
73
+ input.dedupeKey === null
74
+ ? await tx.execute(sql`
75
+ insert into notifications
76
+ (user_id, kind, data, href, dedupe_key, created_at, updated_at)
77
+ values (${input.userId}, ${input.kind}, ${payload}::jsonb, ${input.href},
78
+ null, ${input.at}, ${input.at})
79
+ returning id, occurrences
80
+ `)
81
+ : await tx.execute(sql`
82
+ insert into notifications
83
+ (user_id, kind, data, href, dedupe_key, created_at, updated_at)
84
+ values (${input.userId}, ${input.kind}, ${payload}::jsonb, ${input.href},
85
+ ${input.dedupeKey}, ${input.at}, ${input.at})
86
+ on conflict (user_id, dedupe_key)
87
+ where read_at is null and dedupe_key is not null
88
+ do update
89
+ set occurrences = notifications.occurrences + 1,
90
+ /*
91
+ * The newest facts win: an administrator reading "this
92
+ * task failed 40 times" wants the *last* error, not the
93
+ * first one from six hours ago.
94
+ */
95
+ data = excluded.data,
96
+ href = excluded.href,
97
+ updated_at = excluded.updated_at
98
+ returning id, occurrences
99
+ `),
100
+ ) as Array<{ id: number; occurrences: number }>
101
+
102
+ const row = rows[0]
103
+ if (row === undefined) throw new Error('Notification insert returned no row')
104
+
105
+ const notificationId = Number(row.id)
106
+ const coalesced = Number(row.occurrences) > 1
107
+
108
+ const emailQueued = input.email && !coalesced
109
+ const pushQueued = input.push && !coalesced
110
+
111
+ if (emailQueued || pushQueued) {
112
+ await tx.execute(sql`
113
+ insert into outbox (topic, payload)
114
+ values (
115
+ 'notification.created',
116
+ ${JSON.stringify({
117
+ notificationId,
118
+ userId: input.userId,
119
+ kind: input.kind,
120
+ })}::jsonb
121
+ )
122
+ `)
123
+ }
124
+
125
+ return { notificationId, coalesced, emailQueued, pushQueued }
126
+ })
127
+ }
128
+
129
+ async listFor(
130
+ userId: number,
131
+ options: {
132
+ readonly limit: number
133
+ readonly after?: string
134
+ readonly offset?: number
135
+ },
136
+ ): Promise<NotificationPage> {
137
+ const cursor = options.after === undefined ? null : decodeCursor(options.after)
138
+ const after = cursor ? sql`and (n.created_at, n.id) < (${cursor.at}, ${cursor.id})` : sql``
139
+
140
+ const rows = resultRows(
141
+ await this.db.execute(sql`
142
+ ${SELECT_NOTIFICATION}
143
+ where n.user_id = ${userId} ${after}
144
+ order by n.created_at desc, n.id desc
145
+ limit ${options.limit + 1} offset ${options.offset ?? 0}
146
+ `),
147
+ ) as RawNotification[]
148
+
149
+ const page = rows.slice(0, options.limit).map(toRecord)
150
+ const last = page.at(-1)
151
+ return rows.length > options.limit && last
152
+ ? { rows: page, nextCursor: encodeCursor(last.createdAt, last.id) }
153
+ : { rows: page }
154
+ }
155
+
156
+ async countFor(userId: number): Promise<number> {
157
+ const rows = resultRows(
158
+ await this.db.execute(sql`
159
+ select count(*)::int as total from notifications where user_id = ${userId}
160
+ `),
161
+ ) as Array<{ total: number }>
162
+
163
+ return Number(rows[0]?.total ?? 0)
164
+ }
165
+
166
+ async unreadCount(userId: number): Promise<number> {
167
+ const rows = resultRows(
168
+ await this.db.execute(sql`
169
+ select count(*)::int as unread
170
+ from notifications
171
+ where user_id = ${userId} and read_at is null
172
+ `),
173
+ ) as Array<{ unread: number }>
174
+ return Number(rows[0]?.unread ?? 0)
175
+ }
176
+
177
+ async markRead(userId: number, notificationId: number): Promise<boolean> {
178
+ const rows = resultRows(
179
+ await this.db.execute(sql`
180
+ update notifications
181
+ set read_at = now()
182
+ where id = ${notificationId} and user_id = ${userId} and read_at is null
183
+ returning id
184
+ `),
185
+ ) as Array<{ id: number }>
186
+ return rows.length > 0
187
+ }
188
+
189
+ async markAllRead(userId: number): Promise<number> {
190
+ const rows = resultRows(
191
+ await this.db.execute(sql`
192
+ update notifications
193
+ set read_at = now()
194
+ where user_id = ${userId} and read_at is null
195
+ returning id
196
+ `),
197
+ ) as Array<{ id: number }>
198
+ return rows.length
199
+ }
200
+
201
+ async preferencesFor(
202
+ userId: number,
203
+ ): Promise<ReadonlyMap<string, NotificationChannelPreference>> {
204
+ const rows = resultRows(
205
+ await this.db.execute(sql`
206
+ select kind, email, push from notification_preferences where user_id = ${userId}
207
+ `),
208
+ ) as Array<{ kind: string; email: boolean | null; push: boolean | null }>
209
+
210
+ return new Map(
211
+ rows.map((row) => [row.kind, { email: toChannel(row.email), push: toChannel(row.push) }]),
212
+ )
213
+ }
214
+
215
+ async savePreferences(
216
+ userId: number,
217
+ channel: NotificationChannel,
218
+ entries: ReadonlyMap<string, boolean>,
219
+ ): Promise<void> {
220
+ if (entries.size === 0) return
221
+
222
+ const values = sql.join(
223
+ [...entries].map(([kind, on]) => sql`(${userId}, ${kind}, ${on}, now())`),
224
+ sql`, `,
225
+ )
226
+
227
+ await this.db.execute(
228
+ channel === 'email'
229
+ ? sql`
230
+ insert into notification_preferences (user_id, kind, email, updated_at)
231
+ values ${values}
232
+ on conflict (user_id, kind)
233
+ do update set email = excluded.email, updated_at = now()
234
+ `
235
+ : sql`
236
+ insert into notification_preferences (user_id, kind, push, updated_at)
237
+ values ${values}
238
+ on conflict (user_id, kind)
239
+ do update set push = excluded.push, updated_at = now()
240
+ `,
241
+ )
242
+ }
243
+
244
+ async savePushSubscription(input: SavePushSubscriptionInput): Promise<void> {
245
+ await this.db.execute(sql`
246
+ insert into push_subscriptions
247
+ (user_id, endpoint, p256dh, auth, created_at, last_seen_at)
248
+ values (${input.userId}, ${input.endpoint}, ${input.p256dh}, ${input.auth},
249
+ ${input.at}, ${input.at})
250
+ on conflict (endpoint)
251
+ do update set user_id = excluded.user_id,
252
+ p256dh = excluded.p256dh,
253
+ auth = excluded.auth,
254
+ last_seen_at = excluded.last_seen_at
255
+ `)
256
+ }
257
+
258
+ async removePushSubscription(userId: number, endpoint: string): Promise<boolean> {
259
+ const rows = resultRows(
260
+ await this.db.execute(sql`
261
+ delete from push_subscriptions
262
+ where user_id = ${userId} and endpoint = ${endpoint}
263
+ returning id
264
+ `),
265
+ ) as Array<{ id: number }>
266
+ return rows.length > 0
267
+ }
268
+
269
+ async pushSubscriptionsFor(userId: number): Promise<readonly PushSubscriptionRecord[]> {
270
+ const rows = resultRows(
271
+ await this.db.execute(sql`
272
+ select id, user_id, endpoint, p256dh, auth, created_at, last_seen_at
273
+ from push_subscriptions
274
+ where user_id = ${userId}
275
+ order by id
276
+ `),
277
+ ) as Array<{
278
+ id: number
279
+ user_id: number
280
+ endpoint: string
281
+ p256dh: string
282
+ auth: string
283
+ created_at: string | Date
284
+ last_seen_at: string | Date
285
+ }>
286
+
287
+ return rows.map((row) => ({
288
+ id: Number(row.id),
289
+ userId: Number(row.user_id),
290
+ endpoint: row.endpoint,
291
+ p256dh: row.p256dh,
292
+ auth: row.auth,
293
+ createdAt: toDate(row.created_at),
294
+ lastSeenAt: toDate(row.last_seen_at),
295
+ }))
296
+ }
297
+
298
+ async countPushSubscriptions(userId: number): Promise<number> {
299
+ const rows = resultRows(
300
+ await this.db.execute(sql`
301
+ select count(*)::int as total from push_subscriptions where user_id = ${userId}
302
+ `),
303
+ ) as Array<{ total: number }>
304
+ return Number(rows[0]?.total ?? 0)
305
+ }
306
+
307
+ async prunePushSubscription(id: number): Promise<void> {
308
+ await this.db.execute(sql`delete from push_subscriptions where id = ${id}`)
309
+ }
310
+
311
+ async touchPushSubscription(id: number, at: Date): Promise<void> {
312
+ await this.db.execute(sql`
313
+ update push_subscriptions set last_seen_at = ${at} where id = ${id}
314
+ `)
315
+ }
316
+
317
+ async findForDelivery(notificationId: number): Promise<DeliverableNotification | null> {
318
+ const rows = resultRows(
319
+ await this.db.execute(sql`
320
+ select n.id, n.user_id, n.kind, n.data, n.href, n.occurrences,
321
+ n.created_at, n.updated_at, n.read_at, n.email_sent_at, n.push_sent_at,
322
+ u.username, u.email, u.locale, u.state,
323
+ p.email as email_preference, p.push as push_preference
324
+ from notifications n
325
+ join users u on u.id = n.user_id
326
+ left join notification_preferences p
327
+ on p.user_id = n.user_id and p.kind = n.kind
328
+ where n.id = ${notificationId}
329
+ `),
330
+ ) as Array<
331
+ RawNotification & {
332
+ email_sent_at: string | Date | null
333
+ push_sent_at: string | Date | null
334
+ username: string
335
+ email: string
336
+ locale: string
337
+ state: string
338
+ email_preference: boolean | null
339
+ push_preference: boolean | null
340
+ }
341
+ >
342
+
343
+ const row = rows[0]
344
+ if (row === undefined) return null
345
+ if (row.state === 'deleted') return null
346
+
347
+ const spec = notificationKind(row.kind)
348
+
349
+ return {
350
+ notification: toRecord(row),
351
+ recipient: {
352
+ userId: Number(row.user_id),
353
+ username: row.username,
354
+ email: row.email,
355
+ locale: row.locale,
356
+ },
357
+ emailEnabled: row.email_preference ?? spec?.emailByDefault ?? false,
358
+ emailSentAt: toNullableDate(row.email_sent_at),
359
+ pushEnabled: row.push_preference ?? spec?.pushByDefault ?? false,
360
+ pushSentAt: toNullableDate(row.push_sent_at),
361
+ }
362
+ }
363
+
364
+ async markEmailSent(notificationId: number, at: Date): Promise<void> {
365
+ await this.db.execute(sql`
366
+ update notifications set email_sent_at = ${at} where id = ${notificationId}
367
+ `)
368
+ }
369
+
370
+ async markPushSent(notificationId: number, at: Date): Promise<void> {
371
+ await this.db.execute(sql`
372
+ update notifications set push_sent_at = ${at} where id = ${notificationId}
373
+ `)
374
+ }
375
+
376
+ async administratorIds(limit: number): Promise<readonly number[]> {
377
+ const rows = resultRows(
378
+ await this.db.execute(sql`
379
+ select u.id
380
+ from users u
381
+ where u.state = 'active'
382
+ and exists (
383
+ select 1
384
+ from usergroups g
385
+ where g.can_access_admin_cp
386
+ and (
387
+ g.id = u.primary_group_id
388
+ or g.id in (
389
+ select m.group_id
390
+ from user_group_memberships m
391
+ where m.user_id = u.id
392
+ )
393
+ )
394
+ )
395
+ order by u.id
396
+ limit ${limit}
397
+ `),
398
+ ) as Array<{ id: number }>
399
+
400
+ return rows.map((row) => Number(row.id))
401
+ }
402
+ }
@@ -0,0 +1,19 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { textList } from './sql-lists'
5
+
6
+ export async function rememberOrphanKey(db: Database, key: string): Promise<void> {
7
+ await db.execute(sql`
8
+ insert into attachment_orphans (storage_key) values (${key})
9
+ on conflict (storage_key) do nothing
10
+ `)
11
+ }
12
+
13
+ export async function forgetOrphanKeys(db: Database, keys: readonly string[]): Promise<void> {
14
+ if (keys.length === 0) return
15
+ await db.execute(sql`
16
+ delete from attachment_orphans
17
+ where storage_key in ${textList(keys)}
18
+ `)
19
+ }