@meith/db 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +165 -0
- package/migrations/0000_initial_schema.sql +538 -0
- package/migrations/0001_seed_usergroups.sql +111 -0
- package/migrations/0002_group_promotions.sql +25 -0
- package/migrations/0003_counter_maintenance.sql +34 -0
- package/migrations/0004_post_render_cache.sql +7 -0
- package/migrations/0005_reports.sql +51 -0
- package/migrations/0006_warnings.sql +91 -0
- package/migrations/0007_notifications.sql +44 -0
- package/migrations/0008_subscriptions.sql +48 -0
- package/migrations/0009_member_preferences.sql +13 -0
- package/migrations/0010_profile_fields.sql +53 -0
- package/migrations/0011_private_messages.sql +63 -0
- package/migrations/0012_user_relations.sql +18 -0
- package/migrations/0013_reputation.sql +45 -0
- package/migrations/0014_signatures.sql +16 -0
- package/migrations/0015_admin_sessions.sql +24 -0
- package/migrations/0016_attachments.sql +51 -0
- package/migrations/0017_avatars.sql +33 -0
- package/migrations/0018_mass_mail.sql +15 -0
- package/migrations/0019_word_filters.sql +8 -0
- package/migrations/0020_searches.sql +15 -0
- package/migrations/0021_presence_stats.sql +18 -0
- package/migrations/0022_api_tokens_webhooks.sql +58 -0
- package/migrations/0023_install_state.sql +6 -0
- package/migrations/0024_component_versions.sql +13 -0
- package/migrations/0025_import.sql +25 -0
- package/migrations/0026_polls.sql +40 -0
- package/migrations/0027_drafts.sql +13 -0
- package/migrations/0028_board_vocabulary.sql +32 -0
- package/migrations/0029_announcements.sql +16 -0
- package/migrations/0030_antispam.sql +18 -0
- package/migrations/0031_markdown.sql +24 -0
- package/migrations/0032_theme_enablement.sql +1 -0
- package/migrations/0033_group_identity.sql +5 -0
- package/migrations/0034_search_document_version.sql +4 -0
- package/migrations/0035_categories_hold_no_threads.sql +1 -0
- package/migrations/0036_expiring_group_grants.sql +6 -0
- package/migrations/0037_automatic_timezone.sql +3 -0
- package/migrations/0038_primary_group_grants.sql +3 -0
- package/migrations/0039_moderator_log_forum_scope.sql +1 -0
- package/migrations/0040_moderator_restore_grants.sql +1 -0
- package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
- package/migrations/0042_drop_placebo_delete_others.sql +2 -0
- package/migrations/0043_federated_identity.sql +28 -0
- package/migrations/0044_second_factor.sql +34 -0
- package/migrations/0045_drop_dead_task_due_index.sql +1 -0
- package/migrations/0046_member_locale.sql +1 -0
- package/migrations/0047_navigation_items.sql +34 -0
- package/migrations/0048_web_push.sql +16 -0
- package/migrations/0049_plugin_health.sql +7 -0
- package/migrations/0050_drop_forum_password_grants.sql +1 -0
- package/migrations/0051_glossy_barracuda.sql +1 -0
- package/migrations/0052_many_professor_monster.sql +2 -0
- package/migrations/0053_strange_maximus.sql +14 -0
- package/migrations/0054_legacy_import_sources.sql +25 -0
- package/migrations/0055_poll_parity.sql +14 -0
- package/migrations/0056_marketplace_catalog.sql +11 -0
- package/migrations/meta/0000_snapshot.json +4412 -0
- package/migrations/meta/0006_snapshot.json +5411 -0
- package/migrations/meta/0007_snapshot.json +5634 -0
- package/migrations/meta/0008_snapshot.json +5779 -0
- package/migrations/meta/0009_snapshot.json +5816 -0
- package/migrations/meta/0010_snapshot.json +6117 -0
- package/migrations/meta/0011_snapshot.json +6406 -0
- package/migrations/meta/0012_snapshot.json +6517 -0
- package/migrations/meta/0013_snapshot.json +6722 -0
- package/migrations/meta/0014_snapshot.json +6777 -0
- package/migrations/meta/0015_snapshot.json +6914 -0
- package/migrations/meta/0016_snapshot.json +7192 -0
- package/migrations/meta/0017_snapshot.json +7248 -0
- package/migrations/meta/0018_snapshot.json +7355 -0
- package/migrations/meta/0019_snapshot.json +7419 -0
- package/migrations/meta/0020_snapshot.json +7562 -0
- package/migrations/meta/0021_snapshot.json +7653 -0
- package/migrations/meta/0031_snapshot.json +8162 -0
- package/migrations/meta/0032_snapshot.json +8169 -0
- package/migrations/meta/0033_snapshot.json +8193 -0
- package/migrations/meta/0034_snapshot.json +8221 -0
- package/migrations/meta/0036_snapshot.json +8262 -0
- package/migrations/meta/0037_snapshot.json +8262 -0
- package/migrations/meta/0038_snapshot.json +8293 -0
- package/migrations/meta/0039_snapshot.json +8308 -0
- package/migrations/meta/0041_snapshot.json +8287 -0
- package/migrations/meta/0042_snapshot.json +8274 -0
- package/migrations/meta/0043_snapshot.json +8521 -0
- package/migrations/meta/0044_snapshot.json +8814 -0
- package/migrations/meta/0045_snapshot.json +8797 -0
- package/migrations/meta/0046_snapshot.json +8804 -0
- package/migrations/meta/0047_snapshot.json +9018 -0
- package/migrations/meta/0048_snapshot.json +9145 -0
- package/migrations/meta/0049_snapshot.json +9190 -0
- package/migrations/meta/0050_snapshot.json +9126 -0
- package/migrations/meta/0051_snapshot.json +9132 -0
- package/migrations/meta/0052_snapshot.json +9148 -0
- package/migrations/meta/0053_snapshot.json +9268 -0
- package/migrations/meta/0054_snapshot.json +9378 -0
- package/migrations/meta/0056_snapshot.json +9447 -0
- package/migrations/meta/_journal.json +405 -0
- package/package.json +64 -0
- package/src/account-repos.ts +580 -0
- package/src/actor-builder.ts +130 -0
- package/src/admin-repo.ts +85 -0
- package/src/admin-session-repo.ts +205 -0
- package/src/admin-undo-repo.ts +128 -0
- package/src/announcement-repo.ts +176 -0
- package/src/antispam-repo.ts +145 -0
- package/src/api-repo.ts +276 -0
- package/src/attachment-admin-repo.ts +161 -0
- package/src/attachment-repo.ts +240 -0
- package/src/authorization-source.ts +138 -0
- package/src/avatar-repo.ts +185 -0
- package/src/ban-repos.ts +146 -0
- package/src/client.ts +85 -0
- package/src/content-admin-repo.ts +298 -0
- package/src/content-counters.ts +152 -0
- package/src/counter-recount.ts +263 -0
- package/src/cursor.ts +16 -0
- package/src/denormalised-username.ts +34 -0
- package/src/discovery-repo.ts +140 -0
- package/src/draft-repo.ts +59 -0
- package/src/feed-repo.ts +219 -0
- package/src/forum-admin-repo.ts +310 -0
- package/src/forum-repo.ts +206 -0
- package/src/group-admin-repo.ts +256 -0
- package/src/group-identity-repo.ts +84 -0
- package/src/identity-link-repo.ts +145 -0
- package/src/import-repo.ts +123 -0
- package/src/import-sink.ts +1377 -0
- package/src/index.ts +316 -0
- package/src/inline-moderation.ts +344 -0
- package/src/install-repo.ts +67 -0
- package/src/latest-repo.ts +115 -0
- package/src/maintenance-repo.ts +54 -0
- package/src/marketplace-repo.ts +111 -0
- package/src/member-profile-repo.ts +82 -0
- package/src/member-settings-repo.ts +160 -0
- package/src/message-repo.ts +384 -0
- package/src/migrate-run.ts +18 -0
- package/src/migrate.ts +129 -0
- package/src/modcp-repo.ts +253 -0
- package/src/moderation-queue.ts +279 -0
- package/src/navigation-repo.ts +288 -0
- package/src/notification-repo.ts +402 -0
- package/src/orphan-keys.ts +19 -0
- package/src/outbox-repo.ts +78 -0
- package/src/permission-version.ts +24 -0
- package/src/permissions-map.ts +52 -0
- package/src/pglite.fixture.ts +87 -0
- package/src/plugin-data.ts +88 -0
- package/src/plugin-grants.ts +369 -0
- package/src/plugin-health-repo.ts +80 -0
- package/src/plugin-purge-repo.ts +91 -0
- package/src/plugin-users.ts +39 -0
- package/src/poll-repo.ts +334 -0
- package/src/post-repo.ts +261 -0
- package/src/post-writes.ts +227 -0
- package/src/presence-repo.ts +220 -0
- package/src/profile-field-repo.ts +182 -0
- package/src/promotion-repo.ts +152 -0
- package/src/read-state-repo.ts +81 -0
- package/src/relation-repo.ts +113 -0
- package/src/render-backfill.ts +110 -0
- package/src/render-signature.ts +33 -0
- package/src/report-repo.ts +349 -0
- package/src/reputation-repo.ts +295 -0
- package/src/result-rows.ts +13 -0
- package/src/row-values.ts +7 -0
- package/src/schema/content.ts +451 -0
- package/src/schema/identity.ts +699 -0
- package/src/schema/index.ts +10 -0
- package/src/schema/messages.ts +76 -0
- package/src/schema/permission-columns.ts +34 -0
- package/src/schema/platform.ts +376 -0
- package/src/schema/structure.ts +164 -0
- package/src/search-repo.ts +359 -0
- package/src/search-store.ts +113 -0
- package/src/seed-groups.ts +11 -0
- package/src/settings-repo.ts +32 -0
- package/src/signature-repo.ts +102 -0
- package/src/sql-lists.ts +17 -0
- package/src/staff-groups.ts +50 -0
- package/src/stats-repo.ts +151 -0
- package/src/subscription-repo.ts +350 -0
- package/src/system-health-repo.ts +130 -0
- package/src/task-repo.ts +104 -0
- package/src/theme-admin-repo.ts +172 -0
- package/src/theme-repo.ts +43 -0
- package/src/thread-audience.ts +45 -0
- package/src/thread-counters.ts +107 -0
- package/src/thread-repo.ts +255 -0
- package/src/thread-surgery.ts +282 -0
- package/src/thread-tools.ts +311 -0
- package/src/thread-views.ts +41 -0
- package/src/thread-writes.ts +356 -0
- package/src/two-factor-repo.ts +235 -0
- package/src/upgrade-repo.ts +66 -0
- package/src/user-admin-repo.ts +367 -0
- package/src/user-bulk-repo.ts +397 -0
- package/src/user-merge-map.ts +111 -0
- package/src/user-merge-repo.ts +304 -0
- package/src/visibility-counters.ts +190 -0
- package/src/visibility.ts +17 -0
- package/src/vocabulary-repo.ts +54 -0
- package/src/warning-repo.ts +328 -0
|
@@ -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
|
+
}
|