@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,85 @@
|
|
|
1
|
+
import { eq, or, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
import { cacheVersions, usergroups, users } from './schema'
|
|
5
|
+
|
|
6
|
+
export interface UserSummary {
|
|
7
|
+
readonly id: number
|
|
8
|
+
readonly username: string
|
|
9
|
+
readonly primaryGroupId: number | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GroupSummary {
|
|
13
|
+
readonly id: number
|
|
14
|
+
readonly key: string
|
|
15
|
+
readonly title: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function numericOr(reference: string): number | null {
|
|
19
|
+
const value = Number(reference)
|
|
20
|
+
return Number.isInteger(value) && reference.trim() !== '' ? value : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class PostgresAdminRepository {
|
|
24
|
+
constructor(private readonly db: Database) {}
|
|
25
|
+
|
|
26
|
+
async findUser(reference: string, usernameLower: string): Promise<UserSummary | null> {
|
|
27
|
+
const id = numericOr(reference)
|
|
28
|
+
const rows = await this.db
|
|
29
|
+
.select({
|
|
30
|
+
id: users.id,
|
|
31
|
+
username: users.username,
|
|
32
|
+
primaryGroupId: users.primaryGroupId,
|
|
33
|
+
})
|
|
34
|
+
.from(users)
|
|
35
|
+
.where(
|
|
36
|
+
id === null
|
|
37
|
+
? eq(users.usernameLower, usernameLower)
|
|
38
|
+
: or(eq(users.id, id), eq(users.usernameLower, usernameLower)),
|
|
39
|
+
)
|
|
40
|
+
.limit(1)
|
|
41
|
+
|
|
42
|
+
return rows[0] ?? null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async findGroup(reference: string): Promise<GroupSummary | null> {
|
|
46
|
+
const id = numericOr(reference)
|
|
47
|
+
const rows = await this.db
|
|
48
|
+
.select({ id: usergroups.id, key: usergroups.key, title: usergroups.title })
|
|
49
|
+
.from(usergroups)
|
|
50
|
+
.where(
|
|
51
|
+
id === null
|
|
52
|
+
? eq(usergroups.key, reference)
|
|
53
|
+
: or(eq(usergroups.id, id), eq(usergroups.key, reference)),
|
|
54
|
+
)
|
|
55
|
+
.limit(1)
|
|
56
|
+
|
|
57
|
+
return rows[0] ?? null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async listGroupKeys(): Promise<string[]> {
|
|
61
|
+
const rows = await this.db.select({ key: usergroups.key }).from(usergroups)
|
|
62
|
+
return rows.map((r) => r.key)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async setPrimaryGroup(userId: number, groupId: number): Promise<void> {
|
|
66
|
+
await this.db.transaction(async (tx) => {
|
|
67
|
+
await tx
|
|
68
|
+
.update(users)
|
|
69
|
+
.set({ primaryGroupId: groupId, displayGroupId: groupId })
|
|
70
|
+
.where(eq(users.id, userId))
|
|
71
|
+
|
|
72
|
+
await tx
|
|
73
|
+
.insert(cacheVersions)
|
|
74
|
+
.values({ key: 'permissions', version: 1 })
|
|
75
|
+
.onConflictDoUpdate({
|
|
76
|
+
target: cacheVersions.key,
|
|
77
|
+
set: { version: sql`${cacheVersions.version} + 1`, bumpedAt: new Date() },
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async registeredGroupId(): Promise<number | null> {
|
|
83
|
+
return (await this.findGroup('registered'))?.id ?? null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AdminLogRepository,
|
|
5
|
+
AdminLogRow,
|
|
6
|
+
AdminSessionRecord,
|
|
7
|
+
AdminSessionRepository,
|
|
8
|
+
} from '@meith/admin'
|
|
9
|
+
|
|
10
|
+
import type { Database } from './client'
|
|
11
|
+
import { resultRows } from './result-rows'
|
|
12
|
+
import { toDate, toNullableDate } from './row-values'
|
|
13
|
+
|
|
14
|
+
interface RawSession {
|
|
15
|
+
id: number
|
|
16
|
+
user_id: number
|
|
17
|
+
ip_prefix: string | null
|
|
18
|
+
authenticated_at: string | Date
|
|
19
|
+
last_seen_at: string | Date
|
|
20
|
+
expires_at: string | Date
|
|
21
|
+
revoked_at: string | Date | null
|
|
22
|
+
created_at: string | Date
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toSession(row: RawSession): AdminSessionRecord {
|
|
26
|
+
return {
|
|
27
|
+
id: Number(row.id),
|
|
28
|
+
userId: Number(row.user_id),
|
|
29
|
+
ipPrefix: row.ip_prefix,
|
|
30
|
+
authenticatedAt: toDate(row.authenticated_at),
|
|
31
|
+
lastSeenAt: toDate(row.last_seen_at),
|
|
32
|
+
expiresAt: toDate(row.expires_at),
|
|
33
|
+
revokedAt: toNullableDate(row.revoked_at),
|
|
34
|
+
createdAt: toDate(row.created_at),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SESSION_COLUMNS = sql`
|
|
39
|
+
id, user_id, ip_prefix, authenticated_at, last_seen_at,
|
|
40
|
+
expires_at, revoked_at, created_at
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
export class PostgresAdminSessionRepository implements AdminSessionRepository {
|
|
44
|
+
constructor(private readonly db: Database) {}
|
|
45
|
+
|
|
46
|
+
async create(input: {
|
|
47
|
+
readonly userId: number
|
|
48
|
+
readonly tokenHash: string
|
|
49
|
+
readonly ipPrefix: string | null
|
|
50
|
+
readonly expiresAt: Date
|
|
51
|
+
readonly at: Date
|
|
52
|
+
}): Promise<AdminSessionRecord> {
|
|
53
|
+
const rows = resultRows(
|
|
54
|
+
await this.db.execute(sql`
|
|
55
|
+
insert into admin_sessions
|
|
56
|
+
(user_id, token_hash, ip_prefix, authenticated_at, last_seen_at,
|
|
57
|
+
expires_at, created_at)
|
|
58
|
+
values (${input.userId}, ${input.tokenHash}, ${input.ipPrefix}, ${input.at},
|
|
59
|
+
${input.at}, ${input.expiresAt}, ${input.at})
|
|
60
|
+
returning ${SESSION_COLUMNS}
|
|
61
|
+
`),
|
|
62
|
+
) as RawSession[]
|
|
63
|
+
|
|
64
|
+
if (rows[0] === undefined) throw new Error('Admin session insert returned no row')
|
|
65
|
+
return toSession(rows[0])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async findLive(tokenHash: string, now: Date): Promise<AdminSessionRecord | null> {
|
|
69
|
+
const rows = resultRows(
|
|
70
|
+
await this.db.execute(sql`
|
|
71
|
+
select ${SESSION_COLUMNS}
|
|
72
|
+
from admin_sessions
|
|
73
|
+
where token_hash = ${tokenHash}
|
|
74
|
+
and revoked_at is null
|
|
75
|
+
and expires_at > ${now}
|
|
76
|
+
`),
|
|
77
|
+
) as RawSession[]
|
|
78
|
+
|
|
79
|
+
return rows[0] === undefined ? null : toSession(rows[0])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async touch(sessionId: number, now: Date, expiresAt: Date, windowSeconds: number): Promise<void> {
|
|
83
|
+
const cutoff = new Date(now.getTime() - windowSeconds * 1000)
|
|
84
|
+
await this.db.execute(sql`
|
|
85
|
+
update admin_sessions
|
|
86
|
+
set last_seen_at = ${now}, expires_at = ${expiresAt}
|
|
87
|
+
where id = ${sessionId}
|
|
88
|
+
and revoked_at is null
|
|
89
|
+
and last_seen_at < ${cutoff}
|
|
90
|
+
`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async markReauthenticated(sessionId: number, at: Date): Promise<void> {
|
|
94
|
+
await this.db.execute(sql`
|
|
95
|
+
update admin_sessions
|
|
96
|
+
set authenticated_at = ${at}, last_seen_at = ${at}
|
|
97
|
+
where id = ${sessionId} and revoked_at is null
|
|
98
|
+
`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async revoke(sessionId: number, at: Date): Promise<void> {
|
|
102
|
+
await this.db.execute(sql`
|
|
103
|
+
update admin_sessions set revoked_at = ${at}
|
|
104
|
+
where id = ${sessionId} and revoked_at is null
|
|
105
|
+
`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async revokeAllForUser(userId: number, at: Date): Promise<void> {
|
|
109
|
+
await this.db.execute(sql`
|
|
110
|
+
update admin_sessions set revoked_at = ${at}
|
|
111
|
+
where user_id = ${userId} and revoked_at is null
|
|
112
|
+
`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const ACTION_FILTER_LIMIT = 1_000
|
|
117
|
+
|
|
118
|
+
export class PostgresAdminLogRepository implements AdminLogRepository {
|
|
119
|
+
constructor(private readonly db: Database) {}
|
|
120
|
+
|
|
121
|
+
async record(input: {
|
|
122
|
+
readonly userId: number | null
|
|
123
|
+
readonly action: string
|
|
124
|
+
readonly detail: Readonly<Record<string, unknown>>
|
|
125
|
+
readonly ipPrefix: string | null
|
|
126
|
+
readonly at: Date
|
|
127
|
+
}): Promise<void> {
|
|
128
|
+
await this.db.execute(sql`
|
|
129
|
+
insert into admin_log (user_id, action, detail, ip_prefix, created_at)
|
|
130
|
+
values (${input.userId}, ${input.action}, ${JSON.stringify(input.detail)}::jsonb,
|
|
131
|
+
${input.ipPrefix}, ${input.at})
|
|
132
|
+
`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async list(input: {
|
|
136
|
+
readonly limit: number
|
|
137
|
+
readonly offset?: number | undefined
|
|
138
|
+
readonly before?: number | undefined
|
|
139
|
+
readonly action?: string | undefined
|
|
140
|
+
}): Promise<readonly AdminLogRow[]> {
|
|
141
|
+
const before = input.before === undefined ? sql`` : sql`and l.id < ${input.before}`
|
|
142
|
+
const action =
|
|
143
|
+
input.action === undefined || input.action === ''
|
|
144
|
+
? sql``
|
|
145
|
+
: sql`and l.action = ${input.action}`
|
|
146
|
+
|
|
147
|
+
const rows = resultRows(
|
|
148
|
+
await this.db.execute(sql`
|
|
149
|
+
select l.id, l.user_id, l.action, l.detail, l.ip_prefix, l.created_at,
|
|
150
|
+
u.username
|
|
151
|
+
from admin_log l
|
|
152
|
+
left join users u on u.id = l.user_id
|
|
153
|
+
where true ${before} ${action}
|
|
154
|
+
order by l.id desc
|
|
155
|
+
limit ${input.limit} offset ${input.offset ?? 0}
|
|
156
|
+
`),
|
|
157
|
+
) as Array<{
|
|
158
|
+
id: number
|
|
159
|
+
user_id: number | null
|
|
160
|
+
action: string
|
|
161
|
+
detail: unknown
|
|
162
|
+
ip_prefix: string | null
|
|
163
|
+
created_at: string | Date
|
|
164
|
+
username: string | null
|
|
165
|
+
}>
|
|
166
|
+
|
|
167
|
+
return rows.map((row) => ({
|
|
168
|
+
id: Number(row.id),
|
|
169
|
+
userId: row.user_id === null ? null : Number(row.user_id),
|
|
170
|
+
username: row.username,
|
|
171
|
+
action: row.action,
|
|
172
|
+
detail:
|
|
173
|
+
typeof row.detail === 'object' && row.detail !== null && !Array.isArray(row.detail)
|
|
174
|
+
? (row.detail as Record<string, unknown>)
|
|
175
|
+
: {},
|
|
176
|
+
ipPrefix: row.ip_prefix,
|
|
177
|
+
createdAt: toDate(row.created_at),
|
|
178
|
+
}))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async count(input: { readonly action?: string | undefined }): Promise<number> {
|
|
182
|
+
const action =
|
|
183
|
+
input.action === undefined || input.action === ''
|
|
184
|
+
? sql``
|
|
185
|
+
: sql`and l.action = ${input.action}`
|
|
186
|
+
|
|
187
|
+
const rows = resultRows(
|
|
188
|
+
await this.db.execute(sql`
|
|
189
|
+
select count(*)::int as total from admin_log l where true ${action}
|
|
190
|
+
`),
|
|
191
|
+
) as Array<{ total: number }>
|
|
192
|
+
|
|
193
|
+
return Number(rows[0]?.total ?? 0)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async actions(limit = ACTION_FILTER_LIMIT): Promise<readonly string[]> {
|
|
197
|
+
const rows = resultRows(
|
|
198
|
+
await this.db.execute(sql`
|
|
199
|
+
select distinct action from admin_log order by action limit ${limit}
|
|
200
|
+
`),
|
|
201
|
+
) as Array<{ action: string }>
|
|
202
|
+
|
|
203
|
+
return rows.map((row) => row.action)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
import { resultRows } from './result-rows'
|
|
5
|
+
import { toDate, toNullableDate } from './row-values'
|
|
6
|
+
|
|
7
|
+
export const ADMIN_UNDO_MAX_SNAPSHOT_BYTES = 64 * 1024
|
|
8
|
+
|
|
9
|
+
export interface AdminUndoOperation {
|
|
10
|
+
readonly id: number
|
|
11
|
+
readonly actorUserId: number
|
|
12
|
+
readonly operation: string
|
|
13
|
+
readonly snapshot: Readonly<Record<string, unknown>>
|
|
14
|
+
readonly createdAt: Date
|
|
15
|
+
readonly expiresAt: Date
|
|
16
|
+
readonly consumedAt: Date | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RawUndoOperation {
|
|
20
|
+
id: number
|
|
21
|
+
actor_user_id: number
|
|
22
|
+
operation: string
|
|
23
|
+
snapshot: unknown
|
|
24
|
+
created_at: string | Date
|
|
25
|
+
expires_at: string | Date
|
|
26
|
+
consumed_at: string | Date | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mapped(row: RawUndoOperation): AdminUndoOperation {
|
|
30
|
+
const snapshot =
|
|
31
|
+
typeof row.snapshot === 'object' && row.snapshot !== null && !Array.isArray(row.snapshot)
|
|
32
|
+
? (row.snapshot as Record<string, unknown>)
|
|
33
|
+
: {}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
id: Number(row.id),
|
|
37
|
+
actorUserId: Number(row.actor_user_id),
|
|
38
|
+
operation: row.operation,
|
|
39
|
+
snapshot,
|
|
40
|
+
createdAt: toDate(row.created_at),
|
|
41
|
+
expiresAt: toDate(row.expires_at),
|
|
42
|
+
consumedAt: toNullableDate(row.consumed_at),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const COLUMNS = sql`
|
|
47
|
+
id, actor_user_id, operation, snapshot, created_at, expires_at, consumed_at
|
|
48
|
+
`
|
|
49
|
+
|
|
50
|
+
export class PostgresAdminUndoRepository {
|
|
51
|
+
constructor(private readonly db: Database) {}
|
|
52
|
+
|
|
53
|
+
async create(input: {
|
|
54
|
+
readonly tokenHash: string
|
|
55
|
+
readonly actorUserId: number
|
|
56
|
+
readonly operation: string
|
|
57
|
+
readonly snapshot: Readonly<Record<string, unknown>>
|
|
58
|
+
readonly createdAt: Date
|
|
59
|
+
readonly expiresAt: Date
|
|
60
|
+
}): Promise<void> {
|
|
61
|
+
const encoded = JSON.stringify(input.snapshot)
|
|
62
|
+
if (Buffer.byteLength(encoded, 'utf8') > ADMIN_UNDO_MAX_SNAPSHOT_BYTES) {
|
|
63
|
+
throw new RangeError('admin_undo_snapshot_limit')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await this.db.execute(sql`
|
|
67
|
+
insert into admin_undo_operations
|
|
68
|
+
(token_hash, actor_user_id, operation, snapshot, created_at, expires_at)
|
|
69
|
+
values (${input.tokenHash}, ${input.actorUserId}, ${input.operation},
|
|
70
|
+
${encoded}::jsonb, ${input.createdAt}, ${input.expiresAt})
|
|
71
|
+
`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async findLive(input: {
|
|
75
|
+
readonly tokenHash: string
|
|
76
|
+
readonly actorUserId: number
|
|
77
|
+
readonly now: Date
|
|
78
|
+
}): Promise<AdminUndoOperation | null> {
|
|
79
|
+
const rows = resultRows(
|
|
80
|
+
await this.db.execute(sql`
|
|
81
|
+
select ${COLUMNS}
|
|
82
|
+
from admin_undo_operations
|
|
83
|
+
where token_hash = ${input.tokenHash}
|
|
84
|
+
and actor_user_id = ${input.actorUserId}
|
|
85
|
+
and consumed_at is null
|
|
86
|
+
and expires_at > ${input.now}
|
|
87
|
+
`),
|
|
88
|
+
) as RawUndoOperation[]
|
|
89
|
+
|
|
90
|
+
return rows[0] === undefined ? null : mapped(rows[0])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async claim(input: {
|
|
94
|
+
readonly tokenHash: string
|
|
95
|
+
readonly actorUserId: number
|
|
96
|
+
readonly now: Date
|
|
97
|
+
}): Promise<AdminUndoOperation | null> {
|
|
98
|
+
const rows = resultRows(
|
|
99
|
+
await this.db.execute(sql`
|
|
100
|
+
update admin_undo_operations
|
|
101
|
+
set consumed_at = ${input.now}
|
|
102
|
+
where token_hash = ${input.tokenHash}
|
|
103
|
+
and actor_user_id = ${input.actorUserId}
|
|
104
|
+
and consumed_at is null
|
|
105
|
+
and expires_at > ${input.now}
|
|
106
|
+
returning ${COLUMNS}
|
|
107
|
+
`),
|
|
108
|
+
) as RawUndoOperation[]
|
|
109
|
+
|
|
110
|
+
return rows[0] === undefined ? null : mapped(rows[0])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async removeExpired(now: Date, limit = 500): Promise<number> {
|
|
114
|
+
const rows = resultRows(
|
|
115
|
+
await this.db.execute(sql`
|
|
116
|
+
delete from admin_undo_operations
|
|
117
|
+
where id in (
|
|
118
|
+
select id from admin_undo_operations
|
|
119
|
+
where expires_at <= ${now}
|
|
120
|
+
order by expires_at
|
|
121
|
+
limit ${limit}
|
|
122
|
+
)
|
|
123
|
+
returning id
|
|
124
|
+
`),
|
|
125
|
+
)
|
|
126
|
+
return rows.length
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '@meith/core'
|
|
4
|
+
import { msg } from '@meith/i18n'
|
|
5
|
+
import { BodyFormat, sourceAsMarkdown } from '@meith/markdown'
|
|
6
|
+
|
|
7
|
+
import type { Database } from './client'
|
|
8
|
+
import { resultRows } from './result-rows'
|
|
9
|
+
|
|
10
|
+
export interface AnnouncementRow {
|
|
11
|
+
readonly id: number
|
|
12
|
+
readonly forumId: number | null
|
|
13
|
+
readonly forumTitle: string | null
|
|
14
|
+
readonly forumSlug: string | null
|
|
15
|
+
readonly title: string
|
|
16
|
+
readonly message: string
|
|
17
|
+
readonly authorUserId: number | null
|
|
18
|
+
readonly authorUsername: string
|
|
19
|
+
readonly startsAt: Date
|
|
20
|
+
readonly endsAt: Date | null
|
|
21
|
+
readonly enabled: boolean
|
|
22
|
+
readonly createdAt: Date
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AnnouncementInput {
|
|
26
|
+
readonly forumId: number | null
|
|
27
|
+
readonly title: string
|
|
28
|
+
readonly message: string
|
|
29
|
+
readonly startsAt: Date
|
|
30
|
+
readonly endsAt: Date | null
|
|
31
|
+
readonly enabled: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toRow(row: Record<string, unknown>): AnnouncementRow {
|
|
35
|
+
return {
|
|
36
|
+
id: Number(row.id),
|
|
37
|
+
forumId: row.forum_id === null ? null : Number(row.forum_id),
|
|
38
|
+
forumTitle:
|
|
39
|
+
row.forum_title === null || row.forum_title === undefined ? null : String(row.forum_title),
|
|
40
|
+
forumSlug:
|
|
41
|
+
row.forum_slug === null || row.forum_slug === undefined ? null : String(row.forum_slug),
|
|
42
|
+
title: String(row.title),
|
|
43
|
+
message: sourceAsMarkdown(String(row.message), Number(row.body_format ?? BodyFormat.Markdown)),
|
|
44
|
+
authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
|
|
45
|
+
authorUsername: String(row.author_username),
|
|
46
|
+
startsAt: new Date(String(row.starts_at)),
|
|
47
|
+
endsAt: row.ends_at === null ? null : new Date(String(row.ends_at)),
|
|
48
|
+
enabled: row.enabled === true,
|
|
49
|
+
createdAt: new Date(String(row.created_at)),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class PostgresAnnouncementRepository {
|
|
54
|
+
constructor(private readonly db: Database) {}
|
|
55
|
+
|
|
56
|
+
async live(input: {
|
|
57
|
+
readonly now: Date
|
|
58
|
+
readonly visibleForumIds: readonly number[]
|
|
59
|
+
readonly scope?: number | null
|
|
60
|
+
}): Promise<readonly AnnouncementRow[]> {
|
|
61
|
+
const scope = input.scope ?? null
|
|
62
|
+
|
|
63
|
+
const visible = sql`${sql.raw('ARRAY[')}${
|
|
64
|
+
input.visibleForumIds.length === 0
|
|
65
|
+
? sql.raw('')
|
|
66
|
+
: sql.join(
|
|
67
|
+
input.visibleForumIds.map((id) => sql`${id}`),
|
|
68
|
+
sql`, `,
|
|
69
|
+
)
|
|
70
|
+
}${sql.raw(']::int[]')}`
|
|
71
|
+
|
|
72
|
+
const rows = resultRows(
|
|
73
|
+
await this.db.execute(sql`
|
|
74
|
+
select a.id, a.forum_id, a.title, a.message, a.body_format, a.author_user_id,
|
|
75
|
+
a.author_username, a.starts_at, a.ends_at, a.enabled, a.created_at,
|
|
76
|
+
f.title as forum_title, f.slug as forum_slug
|
|
77
|
+
from announcements a
|
|
78
|
+
left join forums f on f.id = a.forum_id
|
|
79
|
+
where a.enabled
|
|
80
|
+
and a.starts_at <= ${input.now}
|
|
81
|
+
and (a.ends_at is null or a.ends_at > ${input.now})
|
|
82
|
+
and (
|
|
83
|
+
a.forum_id is null
|
|
84
|
+
or (a.forum_id = ${scope} and a.forum_id = any(${visible}))
|
|
85
|
+
)
|
|
86
|
+
order by a.starts_at desc, a.id desc
|
|
87
|
+
`),
|
|
88
|
+
) as Array<Record<string, unknown>>
|
|
89
|
+
|
|
90
|
+
return rows.map(toRow)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async list(): Promise<readonly AnnouncementRow[]> {
|
|
94
|
+
const rows = resultRows(
|
|
95
|
+
await this.db.execute(sql`
|
|
96
|
+
select a.id, a.forum_id, a.title, a.message, a.body_format, a.author_user_id,
|
|
97
|
+
a.author_username, a.starts_at, a.ends_at, a.enabled, a.created_at,
|
|
98
|
+
f.title as forum_title, f.slug as forum_slug
|
|
99
|
+
from announcements a
|
|
100
|
+
left join forums f on f.id = a.forum_id
|
|
101
|
+
order by a.starts_at desc, a.id desc
|
|
102
|
+
`),
|
|
103
|
+
) as Array<Record<string, unknown>>
|
|
104
|
+
|
|
105
|
+
return rows.map(toRow)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async find(id: number): Promise<AnnouncementRow | null> {
|
|
109
|
+
const rows = resultRows(
|
|
110
|
+
await this.db.execute(sql`
|
|
111
|
+
select a.id, a.forum_id, a.title, a.message, a.body_format, a.author_user_id,
|
|
112
|
+
a.author_username, a.starts_at, a.ends_at, a.enabled, a.created_at,
|
|
113
|
+
f.title as forum_title, f.slug as forum_slug
|
|
114
|
+
from announcements a
|
|
115
|
+
left join forums f on f.id = a.forum_id
|
|
116
|
+
where a.id = ${id}
|
|
117
|
+
`),
|
|
118
|
+
) as Array<Record<string, unknown>>
|
|
119
|
+
|
|
120
|
+
const row = rows[0]
|
|
121
|
+
return row === undefined ? null : toRow(row)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async create(
|
|
125
|
+
input: AnnouncementInput & { readonly authorUserId: number | null },
|
|
126
|
+
): Promise<number> {
|
|
127
|
+
assertValid(input)
|
|
128
|
+
|
|
129
|
+
const rows = resultRows(
|
|
130
|
+
await this.db.execute(sql`
|
|
131
|
+
insert into announcements (forum_id, title, message, body_format,
|
|
132
|
+
author_user_id, author_username, starts_at,
|
|
133
|
+
ends_at, enabled)
|
|
134
|
+
values (${input.forumId}, ${input.title}, ${input.message},
|
|
135
|
+
${BodyFormat.Markdown}, ${input.authorUserId},
|
|
136
|
+
coalesce((select username from users where id = ${input.authorUserId}), ''),
|
|
137
|
+
${input.startsAt}, ${input.endsAt}, ${input.enabled})
|
|
138
|
+
returning id
|
|
139
|
+
`),
|
|
140
|
+
) as Array<{ id: number }>
|
|
141
|
+
|
|
142
|
+
return Number(rows[0]?.id)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async update(id: number, input: AnnouncementInput): Promise<void> {
|
|
146
|
+
assertValid(input)
|
|
147
|
+
|
|
148
|
+
const rows = resultRows(
|
|
149
|
+
await this.db.execute(sql`
|
|
150
|
+
update announcements
|
|
151
|
+
set forum_id = ${input.forumId}, title = ${input.title},
|
|
152
|
+
message = ${input.message}, body_format = ${BodyFormat.Markdown},
|
|
153
|
+
starts_at = ${input.startsAt},
|
|
154
|
+
ends_at = ${input.endsAt}, enabled = ${input.enabled}
|
|
155
|
+
where id = ${id}
|
|
156
|
+
returning id
|
|
157
|
+
`),
|
|
158
|
+
) as Array<{ id: number }>
|
|
159
|
+
|
|
160
|
+
if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-announcement'))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async delete(id: number): Promise<void> {
|
|
164
|
+
await this.db.execute(sql`delete from announcements where id = ${id}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function assertValid(input: AnnouncementInput): void {
|
|
169
|
+
if (input.title.trim() === '') throw new ValidationError(msg('error.db.announcement-needs-title'))
|
|
170
|
+
if (input.message.trim() === '') {
|
|
171
|
+
throw new ValidationError(msg('error.db.announcement-needs-something'))
|
|
172
|
+
}
|
|
173
|
+
if (input.endsAt !== null && input.endsAt <= input.startsAt) {
|
|
174
|
+
throw new ValidationError(msg('error.db.announcement-finish-before-starts'))
|
|
175
|
+
}
|
|
176
|
+
}
|