@meith/db 0.28.0 → 0.29.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.
@@ -0,0 +1,3 @@
1
+ INSERT INTO "navigation_items" ("key", "href", "display_order", "audience")
2
+ VALUES ('members', '/members', 60, 'all'),
3
+ ('staff', '/staff', 70, 'all');
@@ -414,6 +414,13 @@
414
414
  "when": 1787771682008,
415
415
  "tag": "0058_mass_mail_opt_in",
416
416
  "breakpoints": true
417
+ },
418
+ {
419
+ "idx": 59,
420
+ "version": "7",
421
+ "when": 1788876000000,
422
+ "tag": "0059_member_directory_navigation",
423
+ "breakpoints": true
417
424
  }
418
425
  ]
419
426
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/db",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,35 +22,35 @@
22
22
  "dependencies": {
23
23
  "drizzle-orm": "^0.45.2",
24
24
  "postgres": "^3.4.7",
25
- "@meith/accounts": "0.28.0",
26
- "@meith/admin": "0.28.0",
27
- "@meith/antispam": "0.28.0",
28
- "@meith/api": "0.28.0",
29
- "@meith/attachments": "0.28.0",
30
- "@meith/authorization": "0.28.0",
31
- "@meith/avatars": "0.28.0",
32
- "@meith/core": "0.28.0",
33
- "@meith/drafts": "0.28.0",
34
- "@meith/events": "0.28.0",
35
- "@meith/forums": "0.28.0",
36
- "@meith/groups": "0.28.0",
37
- "@meith/i18n": "0.28.0",
38
- "@meith/markdown": "0.28.0",
39
- "@meith/marketplace": "0.28.0",
40
- "@meith/messages": "0.28.0",
41
- "@meith/moderation": "0.28.0",
42
- "@meith/notifications": "0.28.0",
43
- "@meith/plugin-kit": "0.28.0",
44
- "@meith/polls": "0.28.0",
45
- "@meith/profile-fields": "0.28.0",
46
- "@meith/relations": "0.28.0",
47
- "@meith/reputation": "0.28.0",
48
- "@meith/search": "0.28.0",
49
- "@meith/settings": "0.28.0",
50
- "@meith/signatures": "0.28.0",
51
- "@meith/subscriptions": "0.28.0",
52
- "@meith/tasks": "0.28.0",
53
- "@meith/threads": "0.28.0"
25
+ "@meith/accounts": "0.29.0",
26
+ "@meith/admin": "0.29.0",
27
+ "@meith/antispam": "0.29.0",
28
+ "@meith/api": "0.29.0",
29
+ "@meith/attachments": "0.29.0",
30
+ "@meith/authorization": "0.29.0",
31
+ "@meith/avatars": "0.29.0",
32
+ "@meith/core": "0.29.0",
33
+ "@meith/drafts": "0.29.0",
34
+ "@meith/events": "0.29.0",
35
+ "@meith/forums": "0.29.0",
36
+ "@meith/groups": "0.29.0",
37
+ "@meith/i18n": "0.29.0",
38
+ "@meith/markdown": "0.29.0",
39
+ "@meith/marketplace": "0.29.0",
40
+ "@meith/messages": "0.29.0",
41
+ "@meith/moderation": "0.29.0",
42
+ "@meith/notifications": "0.29.0",
43
+ "@meith/plugin-kit": "0.29.0",
44
+ "@meith/polls": "0.29.0",
45
+ "@meith/profile-fields": "0.29.0",
46
+ "@meith/relations": "0.29.0",
47
+ "@meith/search": "0.29.0",
48
+ "@meith/settings": "0.29.0",
49
+ "@meith/signatures": "0.29.0",
50
+ "@meith/subscriptions": "0.29.0",
51
+ "@meith/tasks": "0.29.0",
52
+ "@meith/threads": "0.29.0",
53
+ "@meith/reputation": "0.29.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "drizzle-kit": "^0.31.6",
@@ -15,6 +15,18 @@ export interface GroupIdentity {
15
15
 
16
16
  export interface MemberStanding extends GroupIdentity {
17
17
  readonly reputation: number
18
+ readonly groups: readonly GroupIdentity[]
19
+ }
20
+
21
+ function identityOf(row: Record<string, unknown>): GroupIdentity {
22
+ return {
23
+ groupId: Number(row.group_id),
24
+ title: String(row.title),
25
+ nameColorLight: row.name_color_light === null ? null : String(row.name_color_light),
26
+ nameColorDark: row.name_color_dark === null ? null : String(row.name_color_dark),
27
+ badgeImageLight: row.badge_image_light === null ? null : String(row.badge_image_light),
28
+ badgeImageDark: row.badge_image_dark === null ? null : String(row.badge_image_dark),
29
+ }
18
30
  }
19
31
 
20
32
  export class PostgresGroupIdentityRepository {
@@ -32,33 +44,47 @@ export class PostgresGroupIdentityRepository {
32
44
  g.name_color_light,
33
45
  g.name_color_dark,
34
46
  g.badge_image_light,
35
- g.badge_image_dark
47
+ g.badge_image_dark,
48
+ (g.id = ${displayGroupIdSql('u', 'p')}) as is_display
36
49
  from users u
37
50
  join usergroups p
38
51
  on p.id = u.primary_group_id
39
52
  join usergroups g
40
53
  on g.id = ${displayGroupIdSql('u', 'p')}
54
+ or g.id = u.primary_group_id
55
+ or g.id in (
56
+ select m.group_id
57
+ from user_group_memberships m
58
+ where m.user_id = u.id
59
+ and (m.expires_at is null or m.expires_at > now())
60
+ )
41
61
  where u.id in ${sql`(${sql.join(
42
62
  userIds.map((id) => sql`${id}`),
43
63
  sql`, `,
44
64
  )})`}
65
+ order by u.id, is_display desc, g.display_order, g.title, g.id
45
66
  `),
46
67
  ) as Array<Record<string, unknown>>
47
68
 
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
- )
69
+ const byUser = new Map<number, { reputation: number; groups: GroupIdentity[] }>()
70
+
71
+ for (const row of rows) {
72
+ const userId = Number(row.user_id)
73
+ let entry = byUser.get(userId)
74
+ if (entry === undefined) {
75
+ entry = { reputation: Number(row.reputation ?? 0), groups: [] }
76
+ byUser.set(userId, entry)
77
+ }
78
+ entry.groups.push(identityOf(row))
79
+ }
80
+
81
+ const standings = new Map<number, MemberStanding>()
82
+ for (const [userId, entry] of byUser) {
83
+ const display = entry.groups[0]
84
+ if (display === undefined) continue
85
+ standings.set(userId, { ...display, reputation: entry.reputation, groups: entry.groups })
86
+ }
87
+ return standings
62
88
  }
63
89
 
64
90
  async styled(): Promise<readonly GroupIdentity[]> {
@@ -72,13 +98,6 @@ export class PostgresGroupIdentityRepository {
72
98
  `),
73
99
  ) as Array<Record<string, unknown>>
74
100
 
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
- }))
101
+ return rows.map(identityOf)
83
102
  }
84
103
  }
package/src/index.ts CHANGED
@@ -157,6 +157,13 @@ export {
157
157
  } from './latest-repo'
158
158
  export { PostgresMaintenanceRepository } from './maintenance-repo'
159
159
  export { PostgresMarketplaceCacheRepository } from './marketplace-repo'
160
+ export {
161
+ type MemberDirectoryPage,
162
+ type MemberDirectoryRow,
163
+ type MemberDirectorySort,
164
+ PostgresMemberDirectoryRepository,
165
+ type StaffGroupRow,
166
+ } from './member-directory-repo'
160
167
  export { PostgresMemberProfileRepository } from './member-profile-repo'
161
168
  export { PostgresMemberSettingsRepository } from './member-settings-repo'
162
169
  export { PostgresMessageRepository } from './message-repo'
@@ -0,0 +1,118 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { resultRows } from './result-rows'
5
+
6
+ export type MemberDirectorySort = 'name' | 'posts' | 'joined'
7
+
8
+ export interface MemberDirectoryRow {
9
+ readonly id: number
10
+ readonly username: string
11
+ readonly postCount: number
12
+ readonly createdAt: Date
13
+ }
14
+
15
+ export interface MemberDirectoryPage {
16
+ readonly rows: readonly MemberDirectoryRow[]
17
+ readonly total: number
18
+ }
19
+
20
+ export interface StaffGroupRow {
21
+ readonly groupId: number
22
+ readonly title: string
23
+ readonly description: string | null
24
+ readonly members: readonly { readonly id: number; readonly username: string }[]
25
+ }
26
+
27
+ const SORT_SQL: Readonly<Record<MemberDirectorySort, ReturnType<typeof sql.raw>>> = {
28
+ name: sql.raw('u.username_lower asc, u.id asc'),
29
+ posts: sql.raw('u.post_count desc, u.username_lower asc, u.id asc'),
30
+ joined: sql.raw('u.created_at desc, u.id desc'),
31
+ }
32
+
33
+ export class PostgresMemberDirectoryRepository {
34
+ constructor(private readonly db: Database) {}
35
+
36
+ async page(input: {
37
+ readonly offset: number
38
+ readonly limit: number
39
+ readonly sort: MemberDirectorySort
40
+ readonly nameContains?: string
41
+ }): Promise<MemberDirectoryPage> {
42
+ const name = input.nameContains?.trim().toLowerCase() ?? ''
43
+ const nameFilter =
44
+ name === ''
45
+ ? sql``
46
+ : sql` and u.username_lower like ${`%${name.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`}`
47
+
48
+ const counted = resultRows(
49
+ await this.db.execute(sql`
50
+ select count(*) as total
51
+ from users u
52
+ where u.state <> 'deleted'${nameFilter}
53
+ `),
54
+ ) as Array<Record<string, unknown>>
55
+
56
+ const rows = resultRows(
57
+ await this.db.execute(sql`
58
+ select u.id, u.username, u.post_count, u.created_at
59
+ from users u
60
+ where u.state <> 'deleted'${nameFilter}
61
+ order by ${SORT_SQL[input.sort]}
62
+ limit ${input.limit} offset ${input.offset}
63
+ `),
64
+ ) as Array<Record<string, unknown>>
65
+
66
+ return {
67
+ total: Number(counted[0]?.total ?? 0),
68
+ rows: rows.map((row) => ({
69
+ id: Number(row.id),
70
+ username: String(row.username),
71
+ postCount: Number(row.post_count ?? 0),
72
+ createdAt: new Date(row.created_at as string),
73
+ })),
74
+ }
75
+ }
76
+
77
+ async staff(): Promise<readonly StaffGroupRow[]> {
78
+ const rows = resultRows(
79
+ await this.db.execute(sql`
80
+ select g.id as group_id, g.title, g.description, u.id as user_id, u.username
81
+ from usergroups g
82
+ join users u
83
+ on (u.primary_group_id = g.id
84
+ or exists (
85
+ select 1
86
+ from user_group_memberships m
87
+ where m.user_id = u.id
88
+ and m.group_id = g.id
89
+ and (m.expires_at is null or m.expires_at > now())
90
+ ))
91
+ and u.state <> 'deleted'
92
+ where g.is_staff_group = true
93
+ order by g.display_order, g.id, u.username_lower, u.id
94
+ `),
95
+ ) as Array<Record<string, unknown>>
96
+
97
+ const groups = new Map<
98
+ number,
99
+ { title: string; description: string | null; members: { id: number; username: string }[] }
100
+ >()
101
+
102
+ for (const row of rows) {
103
+ const groupId = Number(row.group_id)
104
+ let entry = groups.get(groupId)
105
+ if (entry === undefined) {
106
+ entry = {
107
+ title: String(row.title),
108
+ description: row.description === null ? null : String(row.description),
109
+ members: [],
110
+ }
111
+ groups.set(groupId, entry)
112
+ }
113
+ entry.members.push({ id: Number(row.user_id), username: String(row.username) })
114
+ }
115
+
116
+ return [...groups].map(([groupId, entry]) => ({ groupId, ...entry }))
117
+ }
118
+ }