@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,206 @@
|
|
|
1
|
+
import { asc, eq, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ForumListingRow,
|
|
5
|
+
ForumRepository,
|
|
6
|
+
ForumRow,
|
|
7
|
+
ForumType,
|
|
8
|
+
LastPostSummary,
|
|
9
|
+
MovePlan,
|
|
10
|
+
MoveTarget,
|
|
11
|
+
NewForum,
|
|
12
|
+
} from '@meith/forums'
|
|
13
|
+
import { childPath, planCreate, planMove } from '@meith/forums'
|
|
14
|
+
|
|
15
|
+
import type { Database } from './client'
|
|
16
|
+
import { forums } from './schema'
|
|
17
|
+
|
|
18
|
+
const FOREST_LOCK_KEY = 0x0f01_0001
|
|
19
|
+
|
|
20
|
+
const FORUM_COLUMNS = {
|
|
21
|
+
id: forums.id,
|
|
22
|
+
type: forums.type,
|
|
23
|
+
title: forums.title,
|
|
24
|
+
slug: forums.slug,
|
|
25
|
+
description: forums.description,
|
|
26
|
+
parentId: forums.parentId,
|
|
27
|
+
path: forums.path,
|
|
28
|
+
depth: forums.depth,
|
|
29
|
+
displayOrder: forums.displayOrder,
|
|
30
|
+
linkUrl: forums.linkUrl,
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
const FORUM_LISTING_COLUMNS = {
|
|
34
|
+
...FORUM_COLUMNS,
|
|
35
|
+
allowThreads: forums.allowThreads,
|
|
36
|
+
threadCount: forums.threadCount,
|
|
37
|
+
postCount: forums.postCount,
|
|
38
|
+
lastPostId: forums.lastPostId,
|
|
39
|
+
lastPostThreadId: forums.lastPostThreadId,
|
|
40
|
+
lastPostThreadTitle: forums.lastPostThreadTitle,
|
|
41
|
+
lastPostUserId: forums.lastPostUserId,
|
|
42
|
+
lastPostUsername: forums.lastPostUsername,
|
|
43
|
+
lastPostAt: forums.lastPostAt,
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
type SelectedForum = {
|
|
47
|
+
[K in keyof typeof FORUM_COLUMNS]: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type SelectedListingForum = {
|
|
51
|
+
[K in keyof typeof FORUM_LISTING_COLUMNS]: unknown
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toForumRow(row: SelectedForum): ForumRow {
|
|
55
|
+
return {
|
|
56
|
+
id: row.id as number,
|
|
57
|
+
type: row.type as ForumType,
|
|
58
|
+
title: row.title as string,
|
|
59
|
+
slug: row.slug as string,
|
|
60
|
+
description: (row.description ?? null) as string | null,
|
|
61
|
+
parentId: (row.parentId ?? null) as number | null,
|
|
62
|
+
path: row.path as string,
|
|
63
|
+
depth: row.depth as number,
|
|
64
|
+
displayOrder: row.displayOrder as number,
|
|
65
|
+
linkUrl: (row.linkUrl ?? null) as string | null,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toLastPost(row: SelectedListingForum): LastPostSummary | null {
|
|
70
|
+
const postId = row.lastPostId as number | null
|
|
71
|
+
const threadId = row.lastPostThreadId as number | null
|
|
72
|
+
const at = row.lastPostAt as Date | null
|
|
73
|
+
|
|
74
|
+
if (postId === null || threadId === null || at === null) return null
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
postId,
|
|
78
|
+
threadId,
|
|
79
|
+
threadTitle: (row.lastPostThreadTitle ?? '') as string,
|
|
80
|
+
userId: (row.lastPostUserId ?? null) as number | null,
|
|
81
|
+
username: (row.lastPostUsername ?? '') as string,
|
|
82
|
+
at,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class PostgresForumRepository implements ForumRepository {
|
|
87
|
+
constructor(private readonly db: Database) {}
|
|
88
|
+
|
|
89
|
+
async listAll(): Promise<ForumRow[]> {
|
|
90
|
+
const rows = await this.db
|
|
91
|
+
.select(FORUM_COLUMNS)
|
|
92
|
+
.from(forums)
|
|
93
|
+
.orderBy(asc(forums.displayOrder), asc(forums.id))
|
|
94
|
+
return rows.map(toForumRow)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async listListing(): Promise<ForumListingRow[]> {
|
|
98
|
+
const rows = await this.db
|
|
99
|
+
.select(FORUM_LISTING_COLUMNS)
|
|
100
|
+
.from(forums)
|
|
101
|
+
.orderBy(asc(forums.displayOrder), asc(forums.id))
|
|
102
|
+
|
|
103
|
+
return rows.map((row) => ({
|
|
104
|
+
...toForumRow(row),
|
|
105
|
+
allowThreads: row.allowThreads as boolean,
|
|
106
|
+
threadCount: row.threadCount as number,
|
|
107
|
+
postCount: row.postCount as number,
|
|
108
|
+
lastPost: toLastPost(row),
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async findById(id: number): Promise<ForumRow | null> {
|
|
113
|
+
const rows = await this.db.select(FORUM_COLUMNS).from(forums).where(eq(forums.id, id)).limit(1)
|
|
114
|
+
const row = rows[0]
|
|
115
|
+
return row ? toForumRow(row) : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async create(input: NewForum): Promise<ForumRow> {
|
|
119
|
+
return this.db.transaction(async (tx) => {
|
|
120
|
+
await tx.execute(sql`select pg_advisory_xact_lock(${FOREST_LOCK_KEY})`)
|
|
121
|
+
|
|
122
|
+
const rows = (await tx.select(FORUM_COLUMNS).from(forums)).map(toForumRow)
|
|
123
|
+
const plan = planCreate(rows, input)
|
|
124
|
+
|
|
125
|
+
const inserted = await tx
|
|
126
|
+
.insert(forums)
|
|
127
|
+
.values({
|
|
128
|
+
type: input.type,
|
|
129
|
+
title: input.title.trim(),
|
|
130
|
+
slug: input.slug,
|
|
131
|
+
description: input.description ?? null,
|
|
132
|
+
parentId: plan.parentId,
|
|
133
|
+
linkUrl: input.linkUrl ?? null,
|
|
134
|
+
allowThreads: input.type === 'forum',
|
|
135
|
+
displayOrder: plan.displayOrder,
|
|
136
|
+
depth: plan.depth,
|
|
137
|
+
path: '',
|
|
138
|
+
})
|
|
139
|
+
.returning({ id: forums.id })
|
|
140
|
+
|
|
141
|
+
const id = inserted[0]?.id
|
|
142
|
+
if (id === undefined) throw new Error('Forum insert returned no id')
|
|
143
|
+
|
|
144
|
+
const path = childPath(plan.parentPath, id)
|
|
145
|
+
await tx.update(forums).set({ path }).where(eq(forums.id, id))
|
|
146
|
+
|
|
147
|
+
const created = await tx.select(FORUM_COLUMNS).from(forums).where(eq(forums.id, id)).limit(1)
|
|
148
|
+
return toForumRow(created[0] as SelectedForum)
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async move(forumId: number, target: MoveTarget): Promise<void> {
|
|
153
|
+
await this.db.transaction(async (tx) => {
|
|
154
|
+
await tx.execute(sql`select pg_advisory_xact_lock(${FOREST_LOCK_KEY})`)
|
|
155
|
+
|
|
156
|
+
const rows = (await tx.select(FORUM_COLUMNS).from(forums).orderBy(asc(forums.id))).map(
|
|
157
|
+
toForumRow,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
await this.applyPlanWith(tx, planMove(rows, forumId, target))
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async applyMove(plan: MovePlan): Promise<void> {
|
|
165
|
+
await this.db.transaction(async (tx) => {
|
|
166
|
+
await tx.execute(sql`select pg_advisory_xact_lock(${FOREST_LOCK_KEY})`)
|
|
167
|
+
await this.applyPlanWith(tx, plan)
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async applyPlanWith(
|
|
172
|
+
tx: Parameters<Parameters<Database['transaction']>[0]>[0],
|
|
173
|
+
plan: MovePlan,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
if (plan.pathUpdates.length > 0) {
|
|
176
|
+
const values = sql.join(
|
|
177
|
+
plan.pathUpdates.map((u) => sql`(${u.id}::int, ${u.path}::text, ${u.depth}::int)`),
|
|
178
|
+
sql`, `,
|
|
179
|
+
)
|
|
180
|
+
await tx.execute(sql`
|
|
181
|
+
update ${forums} as f
|
|
182
|
+
set path = v.path, depth = v.depth, updated_at = now()
|
|
183
|
+
from (values ${values}) as v(id, path, depth)
|
|
184
|
+
where f.id = v.id
|
|
185
|
+
`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await tx
|
|
189
|
+
.update(forums)
|
|
190
|
+
.set({ parentId: plan.newParentId, updatedAt: new Date() })
|
|
191
|
+
.where(eq(forums.id, plan.forumId))
|
|
192
|
+
|
|
193
|
+
if (plan.orderUpdates.length > 0) {
|
|
194
|
+
const values = sql.join(
|
|
195
|
+
plan.orderUpdates.map((o) => sql`(${o.id}::int, ${o.displayOrder}::int)`),
|
|
196
|
+
sql`, `,
|
|
197
|
+
)
|
|
198
|
+
await tx.execute(sql`
|
|
199
|
+
update ${forums} as f
|
|
200
|
+
set display_order = v.display_order, updated_at = now()
|
|
201
|
+
from (values ${values}) as v(id, display_order)
|
|
202
|
+
where f.id = v.id
|
|
203
|
+
`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { PermissionSet } from '@meith/core'
|
|
4
|
+
import { PERMISSION_FIELDS, ValidationError } from '@meith/core'
|
|
5
|
+
import { msg } from '@meith/i18n'
|
|
6
|
+
|
|
7
|
+
import type { Database } from './client'
|
|
8
|
+
import { type Tx, withPermissionVersionBump } from './permission-version'
|
|
9
|
+
import { groupRowToPermissionSet } from './permissions-map'
|
|
10
|
+
import { resultRows } from './result-rows'
|
|
11
|
+
import { columnName } from './schema/permission-columns'
|
|
12
|
+
import { keptDisplayGroupSql } from './staff-groups'
|
|
13
|
+
|
|
14
|
+
export interface GroupSummaryRow {
|
|
15
|
+
readonly id: number
|
|
16
|
+
readonly key: string
|
|
17
|
+
readonly title: string
|
|
18
|
+
readonly description: string | null
|
|
19
|
+
readonly displayOrder: number
|
|
20
|
+
readonly isSystem: boolean
|
|
21
|
+
readonly isStaffGroup: boolean
|
|
22
|
+
readonly pluginGrantable: boolean
|
|
23
|
+
readonly badgeToken: string | null
|
|
24
|
+
readonly nameColorLight: string | null
|
|
25
|
+
readonly nameColorDark: string | null
|
|
26
|
+
readonly badgeImageLight: string | null
|
|
27
|
+
readonly badgeImageDark: string | null
|
|
28
|
+
readonly memberCount: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GroupIdentityInput {
|
|
32
|
+
readonly title: string
|
|
33
|
+
readonly description: string | null
|
|
34
|
+
readonly displayOrder: number
|
|
35
|
+
readonly isStaffGroup: boolean
|
|
36
|
+
readonly pluginGrantable: boolean
|
|
37
|
+
readonly badgeToken: string | null
|
|
38
|
+
readonly nameColorLight: string | null
|
|
39
|
+
readonly nameColorDark: string | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MembershipChunkResult {
|
|
43
|
+
readonly moved: number
|
|
44
|
+
readonly nextCursor: number | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class PostgresGroupAdminRepository {
|
|
48
|
+
constructor(private readonly db: Database) {}
|
|
49
|
+
|
|
50
|
+
async list(): Promise<readonly GroupSummaryRow[]> {
|
|
51
|
+
const rows = resultRows(
|
|
52
|
+
await this.db.execute(sql`
|
|
53
|
+
select g.id, g.key, g.title, g.description, g.display_order,
|
|
54
|
+
g.is_system, g.is_staff_group, g.plugin_grantable, g.badge_token,
|
|
55
|
+
g.name_color_light, g.name_color_dark,
|
|
56
|
+
g.badge_image_light, g.badge_image_dark,
|
|
57
|
+
(select count(*) from users u where u.primary_group_id = g.id)::int
|
|
58
|
+
as member_count
|
|
59
|
+
from usergroups g
|
|
60
|
+
order by g.display_order, g.id
|
|
61
|
+
`),
|
|
62
|
+
) as Array<Record<string, unknown>>
|
|
63
|
+
|
|
64
|
+
return rows.map((row) => ({
|
|
65
|
+
id: Number(row.id),
|
|
66
|
+
key: String(row.key),
|
|
67
|
+
title: String(row.title),
|
|
68
|
+
description: row.description === null ? null : String(row.description),
|
|
69
|
+
displayOrder: Number(row.display_order),
|
|
70
|
+
isSystem: row.is_system === true,
|
|
71
|
+
isStaffGroup: row.is_staff_group === true,
|
|
72
|
+
pluginGrantable: row.plugin_grantable === true,
|
|
73
|
+
badgeToken: row.badge_token === null ? null : String(row.badge_token),
|
|
74
|
+
nameColorLight: row.name_color_light === null ? null : String(row.name_color_light),
|
|
75
|
+
nameColorDark: row.name_color_dark === null ? null : String(row.name_color_dark),
|
|
76
|
+
badgeImageLight: row.badge_image_light === null ? null : String(row.badge_image_light),
|
|
77
|
+
badgeImageDark: row.badge_image_dark === null ? null : String(row.badge_image_dark),
|
|
78
|
+
memberCount: Number(row.member_count),
|
|
79
|
+
}))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async setBadge(
|
|
83
|
+
groupId: number,
|
|
84
|
+
scheme: 'light' | 'dark',
|
|
85
|
+
key: string | null,
|
|
86
|
+
): Promise<string | null> {
|
|
87
|
+
const column = scheme === 'dark' ? sql`badge_image_dark` : sql`badge_image_light`
|
|
88
|
+
|
|
89
|
+
const rows = resultRows(
|
|
90
|
+
await this.db.execute(sql`
|
|
91
|
+
update usergroups u
|
|
92
|
+
set ${column} = ${key}, updated_at = now()
|
|
93
|
+
from usergroups old
|
|
94
|
+
where u.id = ${groupId}
|
|
95
|
+
and old.id = u.id
|
|
96
|
+
returning old.${column} as previous
|
|
97
|
+
`),
|
|
98
|
+
) as Array<Record<string, unknown>>
|
|
99
|
+
|
|
100
|
+
const previous = rows[0]?.previous
|
|
101
|
+
return typeof previous === 'string' ? previous : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async readPermissions(groupId: number): Promise<PermissionSet | null> {
|
|
105
|
+
const rows = resultRows(
|
|
106
|
+
await this.db.execute(sql`select * from usergroups where id = ${groupId}`),
|
|
107
|
+
) as Array<Record<string, unknown>>
|
|
108
|
+
|
|
109
|
+
const row = rows[0]
|
|
110
|
+
return row === undefined ? null : groupRowToPermissionSet(camelise(row))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async updateIdentity(groupId: number, input: GroupIdentityInput): Promise<void> {
|
|
114
|
+
await this.withVersionBump(async (tx) => {
|
|
115
|
+
await tx.execute(sql`
|
|
116
|
+
update usergroups
|
|
117
|
+
set title = ${input.title},
|
|
118
|
+
description = ${input.description},
|
|
119
|
+
display_order = ${input.displayOrder},
|
|
120
|
+
is_staff_group = ${input.isStaffGroup},
|
|
121
|
+
plugin_grantable = ${input.pluginGrantable},
|
|
122
|
+
badge_token = ${input.badgeToken},
|
|
123
|
+
name_color_light = ${input.nameColorLight},
|
|
124
|
+
name_color_dark = ${input.nameColorDark},
|
|
125
|
+
updated_at = now()
|
|
126
|
+
where id = ${groupId}
|
|
127
|
+
`)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async savePermissions(
|
|
132
|
+
groupId: number,
|
|
133
|
+
permissions: Readonly<Record<string, boolean | number>>,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const assignments = PERMISSION_FIELDS.map(
|
|
136
|
+
(field) =>
|
|
137
|
+
sql`${sql.raw(columnName(field.key))} = ${permissions[field.key] ?? field.fallback}`,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
await this.withVersionBump(async (tx) => {
|
|
141
|
+
await tx.execute(sql`
|
|
142
|
+
update usergroups
|
|
143
|
+
set ${sql.join(assignments, sql`, `)}, updated_at = now()
|
|
144
|
+
where id = ${groupId}
|
|
145
|
+
`)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async create(input: {
|
|
150
|
+
readonly key: string
|
|
151
|
+
readonly title: string
|
|
152
|
+
readonly copyFromGroupId: number
|
|
153
|
+
}): Promise<number> {
|
|
154
|
+
const columns = PERMISSION_FIELDS.map((field) => sql.raw(columnName(field.key)))
|
|
155
|
+
|
|
156
|
+
return this.withVersionBump(async (tx) => {
|
|
157
|
+
const rows = resultRows(
|
|
158
|
+
await tx.execute(sql`
|
|
159
|
+
insert into usergroups (key, title, is_system, ${sql.join(columns, sql`, `)})
|
|
160
|
+
select ${input.key}, ${input.title}, false,
|
|
161
|
+
${sql.join(
|
|
162
|
+
PERMISSION_FIELDS.map((field) => sql.raw(columnName(field.key))),
|
|
163
|
+
sql`, `,
|
|
164
|
+
)}
|
|
165
|
+
from usergroups where id = ${input.copyFromGroupId}
|
|
166
|
+
returning id
|
|
167
|
+
`),
|
|
168
|
+
) as Array<{ id: number }>
|
|
169
|
+
|
|
170
|
+
const id = rows[0]?.id
|
|
171
|
+
if (id === undefined) {
|
|
172
|
+
throw new ValidationError(msg('error.db.there-group-copy-from'))
|
|
173
|
+
}
|
|
174
|
+
return Number(id)
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async remove(groupId: number, moveMembersTo: number): Promise<void> {
|
|
179
|
+
if (groupId === moveMembersTo) {
|
|
180
|
+
throw new ValidationError(msg('error.db.members-moved-into-group-being'))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await this.withVersionBump(async (tx) => {
|
|
184
|
+
const rows = resultRows(
|
|
185
|
+
await tx.execute(sql`select is_system from usergroups where id = ${groupId}`),
|
|
186
|
+
) as Array<{ is_system: boolean }>
|
|
187
|
+
|
|
188
|
+
if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-group'))
|
|
189
|
+
if (rows[0].is_system === true) {
|
|
190
|
+
throw new ValidationError(msg('error.db.group-part-how-board-works'))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await tx.execute(sql`
|
|
194
|
+
update users
|
|
195
|
+
set primary_group_id = ${moveMembersTo},
|
|
196
|
+
display_group_id = ${keptDisplayGroupSql({
|
|
197
|
+
column: sql.raw('display_group_id'),
|
|
198
|
+
leavingGroupId: sql`${groupId}`,
|
|
199
|
+
arrivingGroupId: sql`${moveMembersTo}`,
|
|
200
|
+
})}
|
|
201
|
+
where primary_group_id = ${groupId}
|
|
202
|
+
`)
|
|
203
|
+
await tx.execute(sql`delete from usergroups where id = ${groupId}`)
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async moveMembersChunk(input: {
|
|
208
|
+
readonly fromGroupId: number
|
|
209
|
+
readonly toGroupId: number
|
|
210
|
+
readonly afterUserId: number
|
|
211
|
+
readonly limit: number
|
|
212
|
+
}): Promise<MembershipChunkResult> {
|
|
213
|
+
if (input.fromGroupId === input.toGroupId) {
|
|
214
|
+
throw new ValidationError(msg('error.db.choose-two-different-groups'))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return this.withVersionBump(async (tx) => {
|
|
218
|
+
const rows = resultRows(
|
|
219
|
+
await tx.execute(sql`
|
|
220
|
+
update users
|
|
221
|
+
set primary_group_id = ${input.toGroupId},
|
|
222
|
+
display_group_id = ${keptDisplayGroupSql({
|
|
223
|
+
column: sql.raw('display_group_id'),
|
|
224
|
+
leavingGroupId: sql`${input.fromGroupId}`,
|
|
225
|
+
arrivingGroupId: sql`${input.toGroupId}`,
|
|
226
|
+
})}
|
|
227
|
+
where id in (
|
|
228
|
+
select id from users
|
|
229
|
+
where primary_group_id = ${input.fromGroupId} and id > ${input.afterUserId}
|
|
230
|
+
order by id
|
|
231
|
+
limit ${input.limit}
|
|
232
|
+
)
|
|
233
|
+
returning id
|
|
234
|
+
`),
|
|
235
|
+
) as Array<{ id: number }>
|
|
236
|
+
|
|
237
|
+
const ids = rows.map((row) => Number(row.id)).sort((a, b) => a - b)
|
|
238
|
+
return {
|
|
239
|
+
moved: ids.length,
|
|
240
|
+
nextCursor: ids.length < input.limit ? null : (ids.at(-1) ?? null),
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async withVersionBump<T>(work: (tx: Tx) => Promise<T>): Promise<T> {
|
|
246
|
+
return withPermissionVersionBump(this.db, work)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function camelise(row: Record<string, unknown>): Record<string, unknown> {
|
|
251
|
+
const out: Record<string, unknown> = {}
|
|
252
|
+
for (const [key, value] of Object.entries(row)) {
|
|
253
|
+
out[key.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase())] = value
|
|
254
|
+
}
|
|
255
|
+
return out
|
|
256
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
import { resultRows } from './result-rows'
|
|
5
|
+
import { displayGroupIdSql } from './staff-groups'
|
|
6
|
+
|
|
7
|
+
export interface GroupIdentity {
|
|
8
|
+
readonly groupId: number
|
|
9
|
+
readonly title: string
|
|
10
|
+
readonly nameColorLight: string | null
|
|
11
|
+
readonly nameColorDark: string | null
|
|
12
|
+
readonly badgeImageLight: string | null
|
|
13
|
+
readonly badgeImageDark: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MemberStanding extends GroupIdentity {
|
|
17
|
+
readonly reputation: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PostgresGroupIdentityRepository {
|
|
21
|
+
constructor(private readonly db: Database) {}
|
|
22
|
+
|
|
23
|
+
async forUsers(userIds: readonly number[]): Promise<ReadonlyMap<number, MemberStanding>> {
|
|
24
|
+
if (userIds.length === 0) return new Map()
|
|
25
|
+
|
|
26
|
+
const rows = resultRows(
|
|
27
|
+
await this.db.execute(sql`
|
|
28
|
+
select u.id as user_id,
|
|
29
|
+
u.reputation,
|
|
30
|
+
g.id as group_id,
|
|
31
|
+
g.title,
|
|
32
|
+
g.name_color_light,
|
|
33
|
+
g.name_color_dark,
|
|
34
|
+
g.badge_image_light,
|
|
35
|
+
g.badge_image_dark
|
|
36
|
+
from users u
|
|
37
|
+
join usergroups p
|
|
38
|
+
on p.id = u.primary_group_id
|
|
39
|
+
join usergroups g
|
|
40
|
+
on g.id = ${displayGroupIdSql('u', 'p')}
|
|
41
|
+
where u.id in ${sql`(${sql.join(
|
|
42
|
+
userIds.map((id) => sql`${id}`),
|
|
43
|
+
sql`, `,
|
|
44
|
+
)})`}
|
|
45
|
+
`),
|
|
46
|
+
) as Array<Record<string, unknown>>
|
|
47
|
+
|
|
48
|
+
return new Map(
|
|
49
|
+
rows.map((row) => [
|
|
50
|
+
Number(row.user_id),
|
|
51
|
+
{
|
|
52
|
+
groupId: Number(row.group_id),
|
|
53
|
+
title: String(row.title),
|
|
54
|
+
nameColorLight: row.name_color_light === null ? null : String(row.name_color_light),
|
|
55
|
+
nameColorDark: row.name_color_dark === null ? null : String(row.name_color_dark),
|
|
56
|
+
badgeImageLight: row.badge_image_light === null ? null : String(row.badge_image_light),
|
|
57
|
+
badgeImageDark: row.badge_image_dark === null ? null : String(row.badge_image_dark),
|
|
58
|
+
reputation: Number(row.reputation ?? 0),
|
|
59
|
+
},
|
|
60
|
+
]),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async styled(): Promise<readonly GroupIdentity[]> {
|
|
65
|
+
const rows = resultRows(
|
|
66
|
+
await this.db.execute(sql`
|
|
67
|
+
select id as group_id, title, name_color_light, name_color_dark,
|
|
68
|
+
badge_image_light, badge_image_dark
|
|
69
|
+
from usergroups
|
|
70
|
+
where name_color_light is not null or name_color_dark is not null
|
|
71
|
+
order by display_order, id
|
|
72
|
+
`),
|
|
73
|
+
) as Array<Record<string, unknown>>
|
|
74
|
+
|
|
75
|
+
return rows.map((row) => ({
|
|
76
|
+
groupId: Number(row.group_id),
|
|
77
|
+
title: String(row.title),
|
|
78
|
+
nameColorLight: row.name_color_light === null ? null : String(row.name_color_light),
|
|
79
|
+
nameColorDark: row.name_color_dark === null ? null : String(row.name_color_dark),
|
|
80
|
+
badgeImageLight: row.badge_image_light === null ? null : String(row.badge_image_light),
|
|
81
|
+
badgeImageDark: row.badge_image_dark === null ? null : String(row.badge_image_dark),
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { and, asc, eq } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
LinkIdentityInput,
|
|
5
|
+
NewPasskey,
|
|
6
|
+
PasskeyRecord,
|
|
7
|
+
PasskeyRepository,
|
|
8
|
+
UserIdentityRecord,
|
|
9
|
+
UserIdentityRepository,
|
|
10
|
+
} from '@meith/accounts'
|
|
11
|
+
|
|
12
|
+
import type { Database } from './client'
|
|
13
|
+
import { passkeys, userIdentities } from './schema'
|
|
14
|
+
|
|
15
|
+
const IDENTITY_COLUMNS = {
|
|
16
|
+
id: userIdentities.id,
|
|
17
|
+
userId: userIdentities.userId,
|
|
18
|
+
provider: userIdentities.provider,
|
|
19
|
+
subject: userIdentities.subject,
|
|
20
|
+
label: userIdentities.label,
|
|
21
|
+
linkedAt: userIdentities.linkedAt,
|
|
22
|
+
lastUsedAt: userIdentities.lastUsedAt,
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
export class PostgresUserIdentityRepository implements UserIdentityRepository {
|
|
26
|
+
constructor(private readonly db: Database) {}
|
|
27
|
+
|
|
28
|
+
async findBySubject(provider: string, subject: string): Promise<UserIdentityRecord | null> {
|
|
29
|
+
const rows = await this.db
|
|
30
|
+
.select(IDENTITY_COLUMNS)
|
|
31
|
+
.from(userIdentities)
|
|
32
|
+
.where(and(eq(userIdentities.provider, provider), eq(userIdentities.subject, subject)))
|
|
33
|
+
.limit(1)
|
|
34
|
+
return rows[0] ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async listForUser(userId: number): Promise<readonly UserIdentityRecord[]> {
|
|
38
|
+
return this.db
|
|
39
|
+
.select(IDENTITY_COLUMNS)
|
|
40
|
+
.from(userIdentities)
|
|
41
|
+
.where(eq(userIdentities.userId, userId))
|
|
42
|
+
.orderBy(asc(userIdentities.id))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async link(input: LinkIdentityInput): Promise<UserIdentityRecord> {
|
|
46
|
+
const inserted = await this.db
|
|
47
|
+
.insert(userIdentities)
|
|
48
|
+
.values({
|
|
49
|
+
userId: input.userId,
|
|
50
|
+
provider: input.provider,
|
|
51
|
+
subject: input.subject,
|
|
52
|
+
label: input.label,
|
|
53
|
+
linkedAt: input.now,
|
|
54
|
+
})
|
|
55
|
+
.onConflictDoNothing({
|
|
56
|
+
target: [userIdentities.provider, userIdentities.subject],
|
|
57
|
+
})
|
|
58
|
+
.returning(IDENTITY_COLUMNS)
|
|
59
|
+
|
|
60
|
+
const record = inserted[0] ?? (await this.findBySubject(input.provider, input.subject))
|
|
61
|
+
if (record === null || record === undefined) {
|
|
62
|
+
throw new Error('the linked identity vanished between insert and read')
|
|
63
|
+
}
|
|
64
|
+
return record
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async unlink(userId: number, identityId: number): Promise<boolean> {
|
|
68
|
+
const removed = await this.db
|
|
69
|
+
.delete(userIdentities)
|
|
70
|
+
.where(and(eq(userIdentities.id, identityId), eq(userIdentities.userId, userId)))
|
|
71
|
+
.returning({ id: userIdentities.id })
|
|
72
|
+
return removed.length > 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async markUsed(identityId: number, now: Date): Promise<void> {
|
|
76
|
+
await this.db
|
|
77
|
+
.update(userIdentities)
|
|
78
|
+
.set({ lastUsedAt: now })
|
|
79
|
+
.where(eq(userIdentities.id, identityId))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const PASSKEY_COLUMNS = {
|
|
84
|
+
id: passkeys.id,
|
|
85
|
+
userId: passkeys.userId,
|
|
86
|
+
credentialId: passkeys.credentialId,
|
|
87
|
+
publicKey: passkeys.publicKey,
|
|
88
|
+
signCount: passkeys.signCount,
|
|
89
|
+
label: passkeys.label,
|
|
90
|
+
transports: passkeys.transports,
|
|
91
|
+
createdAt: passkeys.createdAt,
|
|
92
|
+
lastUsedAt: passkeys.lastUsedAt,
|
|
93
|
+
} as const
|
|
94
|
+
|
|
95
|
+
export class PostgresPasskeyRepository implements PasskeyRepository {
|
|
96
|
+
constructor(private readonly db: Database) {}
|
|
97
|
+
|
|
98
|
+
async findByCredentialId(credentialId: string): Promise<PasskeyRecord | null> {
|
|
99
|
+
const rows = await this.db
|
|
100
|
+
.select(PASSKEY_COLUMNS)
|
|
101
|
+
.from(passkeys)
|
|
102
|
+
.where(eq(passkeys.credentialId, credentialId))
|
|
103
|
+
.limit(1)
|
|
104
|
+
return rows[0] ?? null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async listForUser(userId: number): Promise<readonly PasskeyRecord[]> {
|
|
108
|
+
return this.db
|
|
109
|
+
.select(PASSKEY_COLUMNS)
|
|
110
|
+
.from(passkeys)
|
|
111
|
+
.where(eq(passkeys.userId, userId))
|
|
112
|
+
.orderBy(asc(passkeys.id))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async create(input: NewPasskey): Promise<PasskeyRecord> {
|
|
116
|
+
const rows = await this.db
|
|
117
|
+
.insert(passkeys)
|
|
118
|
+
.values({
|
|
119
|
+
userId: input.userId,
|
|
120
|
+
credentialId: input.credentialId,
|
|
121
|
+
publicKey: input.publicKey,
|
|
122
|
+
signCount: input.signCount,
|
|
123
|
+
label: input.label,
|
|
124
|
+
transports: input.transports,
|
|
125
|
+
createdAt: input.now,
|
|
126
|
+
})
|
|
127
|
+
.returning(PASSKEY_COLUMNS)
|
|
128
|
+
return rows[0]!
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async remove(userId: number, passkeyId: number): Promise<boolean> {
|
|
132
|
+
const removed = await this.db
|
|
133
|
+
.delete(passkeys)
|
|
134
|
+
.where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId)))
|
|
135
|
+
.returning({ id: passkeys.id })
|
|
136
|
+
return removed.length > 0
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async markUsed(passkeyId: number, signCount: number, now: Date): Promise<void> {
|
|
140
|
+
await this.db
|
|
141
|
+
.update(passkeys)
|
|
142
|
+
.set({ signCount, lastUsedAt: now })
|
|
143
|
+
.where(eq(passkeys.id, passkeyId))
|
|
144
|
+
}
|
|
145
|
+
}
|