@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,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
+ }