@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,67 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
import postgres from 'postgres'
|
|
3
|
+
|
|
4
|
+
import type { Database } from './client'
|
|
5
|
+
import { resultRows } from './result-rows'
|
|
6
|
+
|
|
7
|
+
export const INSTALL_LOCK_KEY = '8626403014885544245'
|
|
8
|
+
|
|
9
|
+
export async function isInstalled(db: Database): Promise<boolean> {
|
|
10
|
+
try {
|
|
11
|
+
const rows = (await db.execute(
|
|
12
|
+
sql`select 1 from install_state where id = 1`,
|
|
13
|
+
)) as unknown as unknown[]
|
|
14
|
+
return rows.length > 0
|
|
15
|
+
} catch {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function countUsers(db: Database): Promise<number | null> {
|
|
21
|
+
try {
|
|
22
|
+
const rows = (await db.execute(sql`select count(*)::int as total from users`)) as unknown as {
|
|
23
|
+
total: number
|
|
24
|
+
}[]
|
|
25
|
+
return rows[0]?.total ?? 0
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function canConnect(db: Database): Promise<boolean> {
|
|
32
|
+
try {
|
|
33
|
+
await db.execute(sql`select 1`)
|
|
34
|
+
return true
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function markInstalled(db: Database, version: string): Promise<boolean> {
|
|
41
|
+
const rows = resultRows(
|
|
42
|
+
await db.execute(sql`
|
|
43
|
+
insert into install_state (id, installed_version)
|
|
44
|
+
values (1, ${version})
|
|
45
|
+
on conflict (id) do nothing
|
|
46
|
+
returning id
|
|
47
|
+
`),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return rows.length > 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function withInstallLock<T>(url: string, body: () => Promise<T>): Promise<T | null> {
|
|
54
|
+
const client = postgres(url, { max: 1, prepare: false, onnotice: () => {} })
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const rows = await client`
|
|
58
|
+
select pg_try_advisory_lock(${INSTALL_LOCK_KEY}::bigint) as locked
|
|
59
|
+
`
|
|
60
|
+
|
|
61
|
+
if (rows[0]?.locked !== true) return null
|
|
62
|
+
|
|
63
|
+
return await body()
|
|
64
|
+
} finally {
|
|
65
|
+
await client.end({ timeout: 5 })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { ContentScope } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { toDate } from './row-values'
|
|
8
|
+
import { audienceIsEmpty, inAudience } from './thread-audience'
|
|
9
|
+
import { visibleIn } from './visibility'
|
|
10
|
+
|
|
11
|
+
export interface LatestScope {
|
|
12
|
+
readonly forumIds: readonly number[]
|
|
13
|
+
readonly ownThreadsOnlyForumIds: readonly number[]
|
|
14
|
+
readonly viewerUserId: number | null
|
|
15
|
+
readonly content: ContentScope
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LatestThreadRow {
|
|
19
|
+
readonly threadId: number
|
|
20
|
+
readonly title: string
|
|
21
|
+
readonly slug: string
|
|
22
|
+
readonly forumId: number
|
|
23
|
+
readonly forumTitle: string
|
|
24
|
+
readonly forumSlug: string
|
|
25
|
+
readonly authorUserId: number | null
|
|
26
|
+
readonly authorUsername: string
|
|
27
|
+
readonly replyCount: number
|
|
28
|
+
readonly createdAt: Date
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LatestPostRow {
|
|
32
|
+
readonly postId: number
|
|
33
|
+
readonly threadId: number
|
|
34
|
+
readonly threadTitle: string
|
|
35
|
+
readonly threadSlug: string
|
|
36
|
+
readonly forumId: number
|
|
37
|
+
readonly forumTitle: string
|
|
38
|
+
readonly forumSlug: string
|
|
39
|
+
readonly authorUserId: number | null
|
|
40
|
+
readonly authorUsername: string
|
|
41
|
+
readonly createdAt: Date
|
|
42
|
+
readonly messageSource: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const EXCERPT_CHARS = 300
|
|
46
|
+
|
|
47
|
+
export class PostgresLatestRepository {
|
|
48
|
+
constructor(private readonly db: Database) {}
|
|
49
|
+
|
|
50
|
+
async threads(limit: number, scope: LatestScope): Promise<readonly LatestThreadRow[]> {
|
|
51
|
+
if (audienceIsEmpty(scope)) return []
|
|
52
|
+
|
|
53
|
+
const rows = resultRows(
|
|
54
|
+
await this.db.execute(sql`
|
|
55
|
+
select t.id, t.title, t.slug, t.forum_id, f.title as forum_title,
|
|
56
|
+
f.slug as forum_slug, t.author_user_id, t.author_username,
|
|
57
|
+
t.reply_count, t.created_at
|
|
58
|
+
from threads t
|
|
59
|
+
join forums f on f.id = t.forum_id
|
|
60
|
+
where ${inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope)}
|
|
61
|
+
and ${visibleIn(sql`t.visibility`, scope.content)}
|
|
62
|
+
order by t.id desc
|
|
63
|
+
limit ${limit}
|
|
64
|
+
`),
|
|
65
|
+
) as Array<Record<string, unknown>>
|
|
66
|
+
|
|
67
|
+
return rows.map((row) => ({
|
|
68
|
+
threadId: Number(row.id),
|
|
69
|
+
title: String(row.title),
|
|
70
|
+
slug: String(row.slug),
|
|
71
|
+
forumId: Number(row.forum_id),
|
|
72
|
+
forumTitle: String(row.forum_title),
|
|
73
|
+
forumSlug: String(row.forum_slug),
|
|
74
|
+
authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
|
|
75
|
+
authorUsername: String(row.author_username),
|
|
76
|
+
replyCount: Number(row.reply_count),
|
|
77
|
+
createdAt: toDate(row.created_at),
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async posts(limit: number, scope: LatestScope): Promise<readonly LatestPostRow[]> {
|
|
82
|
+
if (audienceIsEmpty(scope)) return []
|
|
83
|
+
|
|
84
|
+
const rows = resultRows(
|
|
85
|
+
await this.db.execute(sql`
|
|
86
|
+
select p.id, p.thread_id, p.author_user_id, p.author_username, p.created_at,
|
|
87
|
+
left(p.message, ${EXCERPT_CHARS}) as message,
|
|
88
|
+
t.title as thread_title, t.slug as thread_slug,
|
|
89
|
+
t.forum_id, f.title as forum_title, f.slug as forum_slug
|
|
90
|
+
from posts p
|
|
91
|
+
join threads t on t.id = p.thread_id
|
|
92
|
+
join forums f on f.id = t.forum_id
|
|
93
|
+
where ${inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope)}
|
|
94
|
+
and ${visibleIn(sql`t.visibility`, scope.content)}
|
|
95
|
+
and ${visibleIn(sql`p.visibility`, scope.content)}
|
|
96
|
+
order by p.id desc
|
|
97
|
+
limit ${limit}
|
|
98
|
+
`),
|
|
99
|
+
) as Array<Record<string, unknown>>
|
|
100
|
+
|
|
101
|
+
return rows.map((row) => ({
|
|
102
|
+
postId: Number(row.id),
|
|
103
|
+
threadId: Number(row.thread_id),
|
|
104
|
+
threadTitle: String(row.thread_title),
|
|
105
|
+
threadSlug: String(row.thread_slug),
|
|
106
|
+
forumId: Number(row.forum_id),
|
|
107
|
+
forumTitle: String(row.forum_title),
|
|
108
|
+
forumSlug: String(row.forum_slug),
|
|
109
|
+
authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
|
|
110
|
+
authorUsername: String(row.author_username),
|
|
111
|
+
createdAt: toDate(row.created_at),
|
|
112
|
+
messageSource: String(row.message),
|
|
113
|
+
}))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { and, isNotNull, lt, or, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
import { resultRows } from './result-rows'
|
|
5
|
+
import { credentialTokens, sessions } from './schema'
|
|
6
|
+
|
|
7
|
+
export class PostgresMaintenanceRepository {
|
|
8
|
+
constructor(private readonly db: Database) {}
|
|
9
|
+
|
|
10
|
+
async pruneSessions(now: Date, limit = 5000, revokedGraceHours = 24): Promise<number> {
|
|
11
|
+
const revokedBefore = new Date(now.getTime() - revokedGraceHours * 3_600_000)
|
|
12
|
+
|
|
13
|
+
const result = await this.db.execute(sql`
|
|
14
|
+
delete from ${sessions}
|
|
15
|
+
where id in (
|
|
16
|
+
select id from ${sessions}
|
|
17
|
+
where expires_at <= ${now}
|
|
18
|
+
or (revoked_at is not null and revoked_at <= ${revokedBefore})
|
|
19
|
+
limit ${limit}
|
|
20
|
+
)
|
|
21
|
+
returning id
|
|
22
|
+
`)
|
|
23
|
+
|
|
24
|
+
return resultRows(result).length
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async pruneExpiredTokens(now: Date, limit = 5000): Promise<number> {
|
|
28
|
+
const result = await this.db.execute(sql`
|
|
29
|
+
delete from ${credentialTokens}
|
|
30
|
+
where id in (
|
|
31
|
+
select id from ${credentialTokens}
|
|
32
|
+
where expires_at <= ${now} or consumed_at is not null
|
|
33
|
+
limit ${limit}
|
|
34
|
+
)
|
|
35
|
+
returning id
|
|
36
|
+
`)
|
|
37
|
+
|
|
38
|
+
return resultRows(result).length
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async countPrunableSessions(now: Date, revokedGraceHours = 24): Promise<number> {
|
|
42
|
+
const revokedBefore = new Date(now.getTime() - revokedGraceHours * 3_600_000)
|
|
43
|
+
const rows = await this.db
|
|
44
|
+
.select({ id: sessions.id })
|
|
45
|
+
.from(sessions)
|
|
46
|
+
.where(
|
|
47
|
+
or(
|
|
48
|
+
lt(sessions.expiresAt, now),
|
|
49
|
+
and(isNotNull(sessions.revokedAt), lt(sessions.revokedAt, revokedBefore)),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
return rows.length
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CachedMarketplace,
|
|
5
|
+
MarketplaceCacheRepository,
|
|
6
|
+
MarketplaceFeed,
|
|
7
|
+
} from '@meith/marketplace'
|
|
8
|
+
|
|
9
|
+
import type { Database } from './client'
|
|
10
|
+
import { resultRows } from './result-rows'
|
|
11
|
+
|
|
12
|
+
interface Row {
|
|
13
|
+
readonly feed: unknown
|
|
14
|
+
readonly source_url: string | null
|
|
15
|
+
readonly fetched_at: string | null
|
|
16
|
+
readonly error: string | null
|
|
17
|
+
readonly error_at: string | null
|
|
18
|
+
readonly notified_updates: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toCached(row: Row | undefined): CachedMarketplace {
|
|
22
|
+
if (row === undefined) {
|
|
23
|
+
return { feed: null, sourceUrl: null, fetchedAt: null, error: null, errorAt: null }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
feed: (row.feed as MarketplaceFeed | null) ?? null,
|
|
28
|
+
sourceUrl: row.source_url,
|
|
29
|
+
fetchedAt: row.fetched_at === null ? null : new Date(row.fetched_at),
|
|
30
|
+
error: row.error,
|
|
31
|
+
errorAt: row.error_at === null ? null : new Date(row.error_at),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The single-row cache behind the Browse tab — see the `marketplace_catalog`
|
|
37
|
+
* table comment in `packages/db/src/schema/platform.ts` for why a table
|
|
38
|
+
* rather than the generic `settings` store: this holds a whole feed
|
|
39
|
+
* document and fetch bookkeeping, not a board-operator preference.
|
|
40
|
+
*/
|
|
41
|
+
export class PostgresMarketplaceCacheRepository implements MarketplaceCacheRepository {
|
|
42
|
+
constructor(private readonly db: Database) {}
|
|
43
|
+
|
|
44
|
+
async read(): Promise<CachedMarketplace> {
|
|
45
|
+
const rows = resultRows(
|
|
46
|
+
await this.db.execute(sql`
|
|
47
|
+
select feed, source_url, fetched_at, error, error_at, notified_updates
|
|
48
|
+
from marketplace_catalog
|
|
49
|
+
where id = 1
|
|
50
|
+
`),
|
|
51
|
+
) as Row[]
|
|
52
|
+
|
|
53
|
+
return toCached(rows[0])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async saveFeed(input: {
|
|
57
|
+
readonly feed: MarketplaceFeed
|
|
58
|
+
readonly sourceUrl: string
|
|
59
|
+
readonly fetchedAt: Date
|
|
60
|
+
}): Promise<void> {
|
|
61
|
+
await this.db.execute(sql`
|
|
62
|
+
insert into marketplace_catalog (id, feed, source_url, fetched_at, error, error_at, updated_at)
|
|
63
|
+
values (1, ${JSON.stringify(input.feed)}::jsonb, ${input.sourceUrl}, ${input.fetchedAt}, null, null, now())
|
|
64
|
+
on conflict (id) do update
|
|
65
|
+
set feed = excluded.feed,
|
|
66
|
+
source_url = excluded.source_url,
|
|
67
|
+
fetched_at = excluded.fetched_at,
|
|
68
|
+
error = null,
|
|
69
|
+
error_at = null,
|
|
70
|
+
updated_at = now()
|
|
71
|
+
`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async saveError(input: { readonly message: string; readonly at: Date }): Promise<void> {
|
|
75
|
+
await this.db.execute(sql`
|
|
76
|
+
insert into marketplace_catalog (id, error, error_at, updated_at)
|
|
77
|
+
values (1, ${input.message}, ${input.at}, now())
|
|
78
|
+
on conflict (id) do update
|
|
79
|
+
set error = excluded.error,
|
|
80
|
+
error_at = excluded.error_at,
|
|
81
|
+
updated_at = now()
|
|
82
|
+
`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async hasNotified(key: string, version: string): Promise<boolean> {
|
|
86
|
+
const rows = resultRows(
|
|
87
|
+
await this.db.execute(sql`
|
|
88
|
+
select 1
|
|
89
|
+
from marketplace_catalog
|
|
90
|
+
where id = 1
|
|
91
|
+
and notified_updates @> ${JSON.stringify([`${key}@${version}`])}::jsonb
|
|
92
|
+
`),
|
|
93
|
+
) as unknown[]
|
|
94
|
+
|
|
95
|
+
return rows.length > 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async markNotified(key: string, version: string): Promise<void> {
|
|
99
|
+
const marker = `${key}@${version}`
|
|
100
|
+
await this.db.execute(sql`
|
|
101
|
+
insert into marketplace_catalog (id, notified_updates, updated_at)
|
|
102
|
+
values (1, ${JSON.stringify([marker])}::jsonb, now())
|
|
103
|
+
on conflict (id) do update
|
|
104
|
+
set notified_updates = (
|
|
105
|
+
select jsonb_agg(distinct value)
|
|
106
|
+
from jsonb_array_elements(marketplace_catalog.notified_updates || excluded.notified_updates)
|
|
107
|
+
),
|
|
108
|
+
updated_at = now()
|
|
109
|
+
`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
MemberProfileRecord,
|
|
5
|
+
MemberProfileRepository,
|
|
6
|
+
MemberSuggestion,
|
|
7
|
+
} from '@meith/accounts'
|
|
8
|
+
|
|
9
|
+
import type { Database } from './client'
|
|
10
|
+
import { resultRows } from './result-rows'
|
|
11
|
+
import { displayGroupIdSql } from './staff-groups'
|
|
12
|
+
|
|
13
|
+
function likePrefix(prefix: string): string {
|
|
14
|
+
return `${prefix.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RawProfile {
|
|
18
|
+
id: number
|
|
19
|
+
username: string
|
|
20
|
+
title: string | null
|
|
21
|
+
post_count: number
|
|
22
|
+
created_at: Date
|
|
23
|
+
last_active_at: Date | null
|
|
24
|
+
location: string | null
|
|
25
|
+
website: string | null
|
|
26
|
+
bio: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class PostgresMemberProfileRepository implements MemberProfileRepository {
|
|
30
|
+
constructor(private readonly db: Database) {}
|
|
31
|
+
|
|
32
|
+
async findPublicById(id: number): Promise<MemberProfileRecord | null> {
|
|
33
|
+
const rows = resultRows(
|
|
34
|
+
await this.db.execute(sql`
|
|
35
|
+
select u.id, u.username, g.title, u.post_count, u.created_at,
|
|
36
|
+
u.last_active_at, u.location, u.website, u.bio
|
|
37
|
+
from users u
|
|
38
|
+
join usergroups p
|
|
39
|
+
on p.id = u.primary_group_id
|
|
40
|
+
left join usergroups g
|
|
41
|
+
on g.id = ${displayGroupIdSql('u', 'p')}
|
|
42
|
+
where u.id = ${id} and u.state <> 'deleted'
|
|
43
|
+
limit 1
|
|
44
|
+
`),
|
|
45
|
+
) as RawProfile[]
|
|
46
|
+
|
|
47
|
+
const row = rows[0]
|
|
48
|
+
if (row === undefined) return null
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
id: Number(row.id),
|
|
52
|
+
username: row.username,
|
|
53
|
+
title: row.title,
|
|
54
|
+
postCount: Number(row.post_count),
|
|
55
|
+
createdAt: new Date(row.created_at),
|
|
56
|
+
lastActiveAt: row.last_active_at === null ? null : new Date(row.last_active_at),
|
|
57
|
+
location: row.location,
|
|
58
|
+
website: row.website,
|
|
59
|
+
bio: row.bio,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async searchByUsernamePrefix(
|
|
64
|
+
prefix: string,
|
|
65
|
+
limit: number,
|
|
66
|
+
): Promise<readonly MemberSuggestion[]> {
|
|
67
|
+
if (prefix === '') return []
|
|
68
|
+
|
|
69
|
+
const rows = resultRows(
|
|
70
|
+
await this.db.execute(sql`
|
|
71
|
+
select id, username
|
|
72
|
+
from users
|
|
73
|
+
where state = 'active'
|
|
74
|
+
and username_lower like ${likePrefix(prefix.toLowerCase())}
|
|
75
|
+
order by username_lower
|
|
76
|
+
limit ${limit}
|
|
77
|
+
`),
|
|
78
|
+
) as Array<{ id: number; username: string }>
|
|
79
|
+
|
|
80
|
+
return rows.map((row) => ({ id: Number(row.id), username: row.username }))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { MemberGroupChoice, MemberSettings, MemberSettingsRepository } from '@meith/accounts'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { isStaffGroupSql } from './staff-groups'
|
|
8
|
+
|
|
9
|
+
interface RawSettings {
|
|
10
|
+
id: number
|
|
11
|
+
email: string
|
|
12
|
+
timezone: string
|
|
13
|
+
locale: string
|
|
14
|
+
posts_per_page: number | null
|
|
15
|
+
threads_per_page: number | null
|
|
16
|
+
invisible: boolean
|
|
17
|
+
location: string | null
|
|
18
|
+
website: string | null
|
|
19
|
+
bio: string | null
|
|
20
|
+
display_group_id: number | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RawGroupChoice {
|
|
24
|
+
id: number
|
|
25
|
+
title: string
|
|
26
|
+
is_primary: boolean
|
|
27
|
+
is_staff: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class PostgresMemberSettingsRepository implements MemberSettingsRepository {
|
|
31
|
+
constructor(private readonly db: Database) {}
|
|
32
|
+
|
|
33
|
+
async read(userId: number): Promise<MemberSettings | null> {
|
|
34
|
+
const rows = resultRows(
|
|
35
|
+
await this.db.execute(sql`
|
|
36
|
+
select id, email, timezone, locale, posts_per_page, threads_per_page,
|
|
37
|
+
invisible, location, website, bio, display_group_id
|
|
38
|
+
from users
|
|
39
|
+
where id = ${userId} and state <> 'deleted'
|
|
40
|
+
`),
|
|
41
|
+
) as RawSettings[]
|
|
42
|
+
|
|
43
|
+
const row = rows[0]
|
|
44
|
+
if (row === undefined) return null
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
userId: Number(row.id),
|
|
48
|
+
email: row.email,
|
|
49
|
+
timezone: row.timezone,
|
|
50
|
+
locale: row.locale,
|
|
51
|
+
postsPerPage: row.posts_per_page === null ? null : Number(row.posts_per_page),
|
|
52
|
+
threadsPerPage: row.threads_per_page === null ? null : Number(row.threads_per_page),
|
|
53
|
+
invisible: row.invisible === true,
|
|
54
|
+
location: row.location,
|
|
55
|
+
website: row.website,
|
|
56
|
+
bio: row.bio,
|
|
57
|
+
displayGroupId: row.display_group_id === null ? null : Number(row.display_group_id),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async groupsHeldBy(userId: number): Promise<readonly MemberGroupChoice[]> {
|
|
62
|
+
const rows = resultRows(
|
|
63
|
+
await this.db.execute(sql`
|
|
64
|
+
select g.id, g.title, (g.id = u.primary_group_id) as is_primary,
|
|
65
|
+
(${isStaffGroupSql('g')}) as is_staff
|
|
66
|
+
from users u
|
|
67
|
+
join usergroups g
|
|
68
|
+
on g.id = u.primary_group_id
|
|
69
|
+
or g.id in (
|
|
70
|
+
select m.group_id
|
|
71
|
+
from user_group_memberships m
|
|
72
|
+
where m.user_id = u.id
|
|
73
|
+
and (m.expires_at is null or m.expires_at > now())
|
|
74
|
+
)
|
|
75
|
+
where u.id = ${userId} and u.state <> 'deleted'
|
|
76
|
+
order by is_primary desc, g.display_order, g.title
|
|
77
|
+
`),
|
|
78
|
+
) as RawGroupChoice[]
|
|
79
|
+
|
|
80
|
+
return rows.map((row) => ({
|
|
81
|
+
groupId: Number(row.id),
|
|
82
|
+
title: row.title,
|
|
83
|
+
isPrimary: row.is_primary === true,
|
|
84
|
+
isStaff: row.is_staff === true,
|
|
85
|
+
}))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async saveDisplayGroup(input: {
|
|
89
|
+
readonly userId: number
|
|
90
|
+
readonly displayGroupId: number | null
|
|
91
|
+
}): Promise<void> {
|
|
92
|
+
await this.db.execute(sql`
|
|
93
|
+
update users
|
|
94
|
+
set display_group_id = ${input.displayGroupId},
|
|
95
|
+
updated_at = now()
|
|
96
|
+
where id = ${input.userId}
|
|
97
|
+
`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async saveProfile(input: {
|
|
101
|
+
readonly userId: number
|
|
102
|
+
readonly location: string | null
|
|
103
|
+
readonly website: string | null
|
|
104
|
+
readonly bio: string | null
|
|
105
|
+
}): Promise<void> {
|
|
106
|
+
await this.db.execute(sql`
|
|
107
|
+
update users
|
|
108
|
+
set location = ${input.location},
|
|
109
|
+
website = ${input.website},
|
|
110
|
+
bio = ${input.bio},
|
|
111
|
+
updated_at = now()
|
|
112
|
+
where id = ${input.userId}
|
|
113
|
+
`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async saveOptions(input: {
|
|
117
|
+
readonly userId: number
|
|
118
|
+
readonly timezone: string
|
|
119
|
+
readonly locale: string
|
|
120
|
+
readonly postsPerPage: number | null
|
|
121
|
+
readonly threadsPerPage: number | null
|
|
122
|
+
readonly invisible: boolean
|
|
123
|
+
}): Promise<void> {
|
|
124
|
+
await this.db.execute(sql`
|
|
125
|
+
update users
|
|
126
|
+
set timezone = ${input.timezone},
|
|
127
|
+
locale = ${input.locale},
|
|
128
|
+
posts_per_page = ${input.postsPerPage},
|
|
129
|
+
threads_per_page = ${input.threadsPerPage},
|
|
130
|
+
invisible = ${input.invisible},
|
|
131
|
+
updated_at = now()
|
|
132
|
+
where id = ${input.userId}
|
|
133
|
+
`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async adoptEmail(input: {
|
|
137
|
+
readonly userId: number
|
|
138
|
+
readonly email: string
|
|
139
|
+
readonly emailLower: string
|
|
140
|
+
}): Promise<boolean> {
|
|
141
|
+
const rows = resultRows(
|
|
142
|
+
await this.db.execute(sql`
|
|
143
|
+
update users u
|
|
144
|
+
set email = ${input.email},
|
|
145
|
+
email_lower = ${input.emailLower},
|
|
146
|
+
email_verified_at = now(),
|
|
147
|
+
updated_at = now()
|
|
148
|
+
where u.id = ${input.userId}
|
|
149
|
+
and u.state <> 'deleted'
|
|
150
|
+
and not exists (
|
|
151
|
+
select 1 from users other
|
|
152
|
+
where other.email_lower = ${input.emailLower} and other.id <> u.id
|
|
153
|
+
)
|
|
154
|
+
returning u.id
|
|
155
|
+
`),
|
|
156
|
+
) as Array<{ id: number }>
|
|
157
|
+
|
|
158
|
+
return rows.length > 0
|
|
159
|
+
}
|
|
160
|
+
}
|