@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,182 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type FieldType,
|
|
5
|
+
type ProfileFieldDefinition,
|
|
6
|
+
type ProfileFieldGroupRule,
|
|
7
|
+
type ProfileFieldRepository,
|
|
8
|
+
type ProfileFieldValue,
|
|
9
|
+
parseFieldType,
|
|
10
|
+
} from '@meith/profile-fields'
|
|
11
|
+
|
|
12
|
+
import type { Database } from './client'
|
|
13
|
+
import { resultRows } from './result-rows'
|
|
14
|
+
|
|
15
|
+
interface RawField {
|
|
16
|
+
id: number
|
|
17
|
+
key: string
|
|
18
|
+
label: string
|
|
19
|
+
description: string | null
|
|
20
|
+
type: string
|
|
21
|
+
options: unknown
|
|
22
|
+
max_length: number | null
|
|
23
|
+
display_order: number
|
|
24
|
+
is_active: boolean
|
|
25
|
+
required_at_registration: boolean
|
|
26
|
+
default_visible: boolean
|
|
27
|
+
default_editable: boolean
|
|
28
|
+
show_in_postbit: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toOptions(value: unknown): readonly string[] {
|
|
32
|
+
if (!Array.isArray(value)) return []
|
|
33
|
+
return value.filter((entry): entry is string => typeof entry === 'string')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toField(row: RawField): ProfileFieldDefinition {
|
|
37
|
+
return {
|
|
38
|
+
id: Number(row.id),
|
|
39
|
+
key: row.key,
|
|
40
|
+
label: row.label,
|
|
41
|
+
description: row.description,
|
|
42
|
+
type: parseFieldType(row.type),
|
|
43
|
+
options: toOptions(row.options),
|
|
44
|
+
maxLength: row.max_length === null ? null : Number(row.max_length),
|
|
45
|
+
displayOrder: Number(row.display_order),
|
|
46
|
+
isActive: row.is_active === true,
|
|
47
|
+
requiredAtRegistration: row.required_at_registration === true,
|
|
48
|
+
defaultVisible: row.default_visible === true,
|
|
49
|
+
defaultEditable: row.default_editable === true,
|
|
50
|
+
showInPostbit: row.show_in_postbit === true,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SELECT_FIELD = sql`
|
|
55
|
+
select id, key, label, description, type, options, max_length, display_order,
|
|
56
|
+
is_active, required_at_registration, default_visible, default_editable,
|
|
57
|
+
show_in_postbit
|
|
58
|
+
from profile_fields
|
|
59
|
+
`
|
|
60
|
+
|
|
61
|
+
export class PostgresProfileFieldRepository implements ProfileFieldRepository {
|
|
62
|
+
constructor(private readonly db: Database) {}
|
|
63
|
+
|
|
64
|
+
async listFields(): Promise<readonly ProfileFieldDefinition[]> {
|
|
65
|
+
const rows = resultRows(
|
|
66
|
+
await this.db.execute(sql`${SELECT_FIELD} order by display_order, id`),
|
|
67
|
+
) as RawField[]
|
|
68
|
+
|
|
69
|
+
return rows.map(toField)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async listGroupRules(): Promise<readonly ProfileFieldGroupRule[]> {
|
|
73
|
+
const rows = resultRows(
|
|
74
|
+
await this.db.execute(sql`
|
|
75
|
+
select field_id, group_id, can_view, can_edit from profile_field_groups
|
|
76
|
+
`),
|
|
77
|
+
) as Array<{
|
|
78
|
+
field_id: number
|
|
79
|
+
group_id: number
|
|
80
|
+
can_view: boolean | null
|
|
81
|
+
can_edit: boolean | null
|
|
82
|
+
}>
|
|
83
|
+
|
|
84
|
+
return rows.map((row) => ({
|
|
85
|
+
fieldId: Number(row.field_id),
|
|
86
|
+
groupId: Number(row.group_id),
|
|
87
|
+
canView: row.can_view,
|
|
88
|
+
canEdit: row.can_edit,
|
|
89
|
+
}))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async valuesFor(userId: number): Promise<readonly ProfileFieldValue[]> {
|
|
93
|
+
const rows = resultRows(
|
|
94
|
+
await this.db.execute(sql`
|
|
95
|
+
select field_id, value from profile_field_values where user_id = ${userId}
|
|
96
|
+
`),
|
|
97
|
+
) as Array<{ field_id: number; value: string }>
|
|
98
|
+
|
|
99
|
+
return rows.map((row) => ({ fieldId: Number(row.field_id), value: row.value }))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async saveValues(input: {
|
|
103
|
+
readonly userId: number
|
|
104
|
+
readonly values: readonly ProfileFieldValue[]
|
|
105
|
+
}): Promise<void> {
|
|
106
|
+
if (input.values.length === 0) return
|
|
107
|
+
|
|
108
|
+
await this.db.transaction(async (tx) => {
|
|
109
|
+
const cleared = input.values.filter((value) => value.value === '')
|
|
110
|
+
const written = input.values.filter((value) => value.value !== '')
|
|
111
|
+
|
|
112
|
+
if (cleared.length > 0) {
|
|
113
|
+
await tx.execute(sql`
|
|
114
|
+
delete from profile_field_values
|
|
115
|
+
where user_id = ${input.userId}
|
|
116
|
+
and field_id in (${sql.join(
|
|
117
|
+
cleared.map((value) => sql`${value.fieldId}`),
|
|
118
|
+
sql`, `,
|
|
119
|
+
)})
|
|
120
|
+
`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (written.length > 0) {
|
|
124
|
+
await tx.execute(sql`
|
|
125
|
+
insert into profile_field_values (user_id, field_id, value, updated_at)
|
|
126
|
+
values ${sql.join(
|
|
127
|
+
written.map((value) => sql`(${input.userId}, ${value.fieldId}, ${value.value}, now())`),
|
|
128
|
+
sql`, `,
|
|
129
|
+
)}
|
|
130
|
+
on conflict (user_id, field_id)
|
|
131
|
+
do update set value = excluded.value, updated_at = now()
|
|
132
|
+
`)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async findByKey(key: string): Promise<ProfileFieldDefinition | null> {
|
|
138
|
+
const rows = resultRows(
|
|
139
|
+
await this.db.execute(sql`${SELECT_FIELD} where key = ${key}`),
|
|
140
|
+
) as RawField[]
|
|
141
|
+
|
|
142
|
+
return rows[0] === undefined ? null : toField(rows[0])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async create(input: {
|
|
146
|
+
readonly key: string
|
|
147
|
+
readonly label: string
|
|
148
|
+
readonly type: FieldType
|
|
149
|
+
readonly options: readonly string[]
|
|
150
|
+
readonly requiredAtRegistration: boolean
|
|
151
|
+
readonly showInPostbit: boolean
|
|
152
|
+
readonly displayOrder: number
|
|
153
|
+
}): Promise<ProfileFieldDefinition> {
|
|
154
|
+
const rows = resultRows(
|
|
155
|
+
await this.db.execute(sql`
|
|
156
|
+
insert into profile_fields
|
|
157
|
+
(key, label, type, options, required_at_registration,
|
|
158
|
+
show_in_postbit, display_order)
|
|
159
|
+
values (${input.key}, ${input.label}, ${input.type},
|
|
160
|
+
${JSON.stringify(input.options)}::jsonb,
|
|
161
|
+
${input.requiredAtRegistration}, ${input.showInPostbit},
|
|
162
|
+
${input.displayOrder})
|
|
163
|
+
returning id, key, label, description, type, options, max_length,
|
|
164
|
+
display_order, is_active, required_at_registration,
|
|
165
|
+
default_visible, default_editable, show_in_postbit
|
|
166
|
+
`),
|
|
167
|
+
) as RawField[]
|
|
168
|
+
|
|
169
|
+
if (rows[0] === undefined) throw new Error('Profile field insert returned no row')
|
|
170
|
+
return toField(rows[0])
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async remove(key: string): Promise<boolean> {
|
|
174
|
+
const rows = resultRows(
|
|
175
|
+
await this.db.execute(sql`
|
|
176
|
+
delete from profile_fields where key = ${key} returning id
|
|
177
|
+
`),
|
|
178
|
+
) as Array<{ id: number }>
|
|
179
|
+
|
|
180
|
+
return rows.length > 0
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { asc, eq, gt, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '@meith/core'
|
|
4
|
+
import type {
|
|
5
|
+
PromotionCandidate,
|
|
6
|
+
PromotionOutcome,
|
|
7
|
+
PromotionRepository,
|
|
8
|
+
PromotionRule,
|
|
9
|
+
PromotionRuleInput,
|
|
10
|
+
PromotionRuleRepository,
|
|
11
|
+
} from '@meith/groups'
|
|
12
|
+
import { promotionRuleProblem } from '@meith/groups'
|
|
13
|
+
import { msg } from '@meith/i18n'
|
|
14
|
+
|
|
15
|
+
import type { Database } from './client'
|
|
16
|
+
import { groupPromotions, users } from './schema'
|
|
17
|
+
import { keptDisplayGroupSql } from './staff-groups'
|
|
18
|
+
|
|
19
|
+
function optionalNumber(value: number | null): number | undefined {
|
|
20
|
+
return value === null ? undefined : value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checked(input: PromotionRuleInput): PromotionRuleInput {
|
|
24
|
+
const problem = promotionRuleProblem(input)
|
|
25
|
+
if (problem !== null) throw new ValidationError(problem)
|
|
26
|
+
return { ...input, title: input.title.trim() }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class PostgresPromotionRepository implements PromotionRepository, PromotionRuleRepository {
|
|
30
|
+
constructor(private readonly db: Database) {}
|
|
31
|
+
|
|
32
|
+
async listRules(): Promise<readonly PromotionRule[]> {
|
|
33
|
+
const rows = await this.db
|
|
34
|
+
.select()
|
|
35
|
+
.from(groupPromotions)
|
|
36
|
+
.orderBy(asc(groupPromotions.displayOrder), asc(groupPromotions.id))
|
|
37
|
+
|
|
38
|
+
return rows.map((r) => ({
|
|
39
|
+
id: r.id,
|
|
40
|
+
title: r.title,
|
|
41
|
+
enabled: r.enabled,
|
|
42
|
+
displayOrder: r.displayOrder,
|
|
43
|
+
minPostCount: optionalNumber(r.minPostCount),
|
|
44
|
+
minReputation: optionalNumber(r.minReputation),
|
|
45
|
+
minDaysRegistered: optionalNumber(r.minDaysRegistered),
|
|
46
|
+
fromPrimaryGroupId: r.fromPrimaryGroupId,
|
|
47
|
+
toPrimaryGroupId: r.toPrimaryGroupId,
|
|
48
|
+
}))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async createRule(input: PromotionRuleInput): Promise<number> {
|
|
52
|
+
const rule = checked(input)
|
|
53
|
+
|
|
54
|
+
const [row] = await this.db
|
|
55
|
+
.insert(groupPromotions)
|
|
56
|
+
.values({
|
|
57
|
+
title: rule.title,
|
|
58
|
+
displayOrder: rule.displayOrder,
|
|
59
|
+
minPostCount: rule.minPostCount,
|
|
60
|
+
minReputation: rule.minReputation,
|
|
61
|
+
minDaysRegistered: rule.minDaysRegistered,
|
|
62
|
+
fromPrimaryGroupId: rule.fromPrimaryGroupId,
|
|
63
|
+
toPrimaryGroupId: rule.toPrimaryGroupId,
|
|
64
|
+
})
|
|
65
|
+
.returning({ id: groupPromotions.id })
|
|
66
|
+
|
|
67
|
+
if (row === undefined) throw new ValidationError(msg('error.db.rule-could-stored'))
|
|
68
|
+
return row.id
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async updateRule(id: number, input: PromotionRuleInput): Promise<void> {
|
|
72
|
+
const rule = checked(input)
|
|
73
|
+
|
|
74
|
+
const rows = await this.db
|
|
75
|
+
.update(groupPromotions)
|
|
76
|
+
.set({
|
|
77
|
+
title: rule.title,
|
|
78
|
+
displayOrder: rule.displayOrder,
|
|
79
|
+
minPostCount: rule.minPostCount,
|
|
80
|
+
minReputation: rule.minReputation,
|
|
81
|
+
minDaysRegistered: rule.minDaysRegistered,
|
|
82
|
+
fromPrimaryGroupId: rule.fromPrimaryGroupId,
|
|
83
|
+
toPrimaryGroupId: rule.toPrimaryGroupId,
|
|
84
|
+
updatedAt: new Date(),
|
|
85
|
+
})
|
|
86
|
+
.where(eq(groupPromotions.id, id))
|
|
87
|
+
.returning({ id: groupPromotions.id })
|
|
88
|
+
|
|
89
|
+
if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-promotion-rule'))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async setRuleEnabled(id: number, enabled: boolean): Promise<void> {
|
|
93
|
+
const rows = await this.db
|
|
94
|
+
.update(groupPromotions)
|
|
95
|
+
.set({ enabled, updatedAt: new Date() })
|
|
96
|
+
.where(eq(groupPromotions.id, id))
|
|
97
|
+
.returning({ id: groupPromotions.id })
|
|
98
|
+
|
|
99
|
+
if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-promotion-rule'))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async deleteRule(id: number): Promise<void> {
|
|
103
|
+
await this.db.delete(groupPromotions).where(eq(groupPromotions.id, id))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async candidates(afterUserId: number, limit: number): Promise<readonly PromotionCandidate[]> {
|
|
107
|
+
const rows = await this.db
|
|
108
|
+
.select({
|
|
109
|
+
userId: users.id,
|
|
110
|
+
postCount: users.postCount,
|
|
111
|
+
reputation: users.reputation,
|
|
112
|
+
registeredAt: users.createdAt,
|
|
113
|
+
primaryGroupId: users.primaryGroupId,
|
|
114
|
+
})
|
|
115
|
+
.from(users)
|
|
116
|
+
.where(gt(users.id, afterUserId))
|
|
117
|
+
.orderBy(asc(users.id))
|
|
118
|
+
.limit(limit)
|
|
119
|
+
|
|
120
|
+
return rows
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async applyPromotions(outcomes: readonly PromotionOutcome[]): Promise<void> {
|
|
124
|
+
if (outcomes.length === 0) return
|
|
125
|
+
|
|
126
|
+
await this.db.transaction(async (tx) => {
|
|
127
|
+
const values = sql.join(
|
|
128
|
+
outcomes.map((o) => sql`(${o.userId}::int, ${o.toPrimaryGroupId}::int)`),
|
|
129
|
+
sql`, `,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const displayGroup = keptDisplayGroupSql({
|
|
133
|
+
column: sql.raw('u.display_group_id'),
|
|
134
|
+
leavingGroupId: sql.raw('u.primary_group_id'),
|
|
135
|
+
arrivingGroupId: sql.raw('v.group_id'),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
await tx.execute(sql`
|
|
139
|
+
update ${users} as u
|
|
140
|
+
set primary_group_id = v.group_id, display_group_id = ${displayGroup}
|
|
141
|
+
from (values ${values}) as v(user_id, group_id)
|
|
142
|
+
where u.id = v.user_id
|
|
143
|
+
`)
|
|
144
|
+
|
|
145
|
+
await tx.execute(sql`
|
|
146
|
+
insert into cache_versions (key, version)
|
|
147
|
+
values ('permissions', 1)
|
|
148
|
+
on conflict (key) do update set version = cache_versions.version + 1, bumped_at = now()
|
|
149
|
+
`)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { and, eq, gt, isNull, or, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { PUBLIC_CONTENT } from '@meith/core'
|
|
4
|
+
import type { ReadState, ReadStateRepository } from '@meith/threads'
|
|
5
|
+
|
|
6
|
+
import type { Database } from './client'
|
|
7
|
+
import { forumsRead, threads, threadsRead } from './schema'
|
|
8
|
+
import { visibleIn } from './visibility'
|
|
9
|
+
|
|
10
|
+
export class PostgresReadStateRepository implements ReadStateRepository {
|
|
11
|
+
constructor(private readonly db: Database) {}
|
|
12
|
+
|
|
13
|
+
async forUser(userId: number): Promise<ReadState> {
|
|
14
|
+
const [forums, threadReads, unread] = await Promise.all([
|
|
15
|
+
this.db
|
|
16
|
+
.select({ forumId: forumsRead.forumId, readAt: forumsRead.readAt })
|
|
17
|
+
.from(forumsRead)
|
|
18
|
+
.where(eq(forumsRead.userId, userId)),
|
|
19
|
+
this.db
|
|
20
|
+
.select({ threadId: threadsRead.threadId, lastReadPostId: threadsRead.lastReadPostId })
|
|
21
|
+
.from(threadsRead)
|
|
22
|
+
.where(eq(threadsRead.userId, userId)),
|
|
23
|
+
this.db
|
|
24
|
+
.selectDistinct({ forumId: threads.forumId })
|
|
25
|
+
.from(threads)
|
|
26
|
+
.leftJoin(
|
|
27
|
+
forumsRead,
|
|
28
|
+
and(eq(forumsRead.userId, userId), eq(forumsRead.forumId, threads.forumId)),
|
|
29
|
+
)
|
|
30
|
+
.leftJoin(
|
|
31
|
+
threadsRead,
|
|
32
|
+
and(eq(threadsRead.userId, userId), eq(threadsRead.threadId, threads.id)),
|
|
33
|
+
)
|
|
34
|
+
.where(
|
|
35
|
+
and(
|
|
36
|
+
visibleIn(threads.visibility, PUBLIC_CONTENT),
|
|
37
|
+
sql`${threads.lastPostId} is not null`,
|
|
38
|
+
or(
|
|
39
|
+
isNull(threadsRead.lastReadPostId),
|
|
40
|
+
gt(threads.lastPostId, threadsRead.lastReadPostId),
|
|
41
|
+
),
|
|
42
|
+
or(isNull(forumsRead.readAt), gt(threads.lastPostAt, forumsRead.readAt)),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
forumReadAt: new Map(forums.map((row) => [row.forumId, row.readAt])),
|
|
49
|
+
threadLastPostId: new Map(
|
|
50
|
+
threadReads.flatMap((row) =>
|
|
51
|
+
row.lastReadPostId === null ? [] : [[row.threadId, row.lastReadPostId] as const],
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
unreadForumIds: new Set(unread.map((row) => row.forumId)),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async markForumsRead(userId: number, forumIds: readonly number[], at: Date): Promise<void> {
|
|
59
|
+
if (forumIds.length === 0) return
|
|
60
|
+
await this.db
|
|
61
|
+
.insert(forumsRead)
|
|
62
|
+
.values(forumIds.map((forumId) => ({ userId, forumId, readAt: at })))
|
|
63
|
+
.onConflictDoUpdate({
|
|
64
|
+
target: [forumsRead.userId, forumsRead.forumId],
|
|
65
|
+
set: { readAt: at },
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async markThreadRead(userId: number, threadId: number, postId: number, at: Date): Promise<void> {
|
|
70
|
+
await this.db
|
|
71
|
+
.insert(threadsRead)
|
|
72
|
+
.values({ userId, threadId, lastReadPostId: postId, readAt: at })
|
|
73
|
+
.onConflictDoUpdate({
|
|
74
|
+
target: [threadsRead.userId, threadsRead.threadId],
|
|
75
|
+
set: {
|
|
76
|
+
lastReadPostId: sql`greatest(coalesce(${threadsRead.lastReadPostId}, 0), excluded.last_read_post_id)`,
|
|
77
|
+
readAt: at,
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { RelationKind, RelationRepository, RelationRow } from '@meith/relations'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { toDate, toNullableDate } from './row-values'
|
|
8
|
+
|
|
9
|
+
export class PostgresRelationRepository implements RelationRepository {
|
|
10
|
+
constructor(private readonly db: Database) {}
|
|
11
|
+
|
|
12
|
+
async list(input: {
|
|
13
|
+
readonly userId: number
|
|
14
|
+
readonly kind: RelationKind
|
|
15
|
+
}): Promise<readonly RelationRow[]> {
|
|
16
|
+
const rows = resultRows(
|
|
17
|
+
await this.db.execute(sql`
|
|
18
|
+
select r.other_user_id, r.kind, r.created_at, u.username, u.last_active_at
|
|
19
|
+
from user_relations r
|
|
20
|
+
join users u on u.id = r.other_user_id
|
|
21
|
+
where r.user_id = ${input.userId}
|
|
22
|
+
and r.kind = ${input.kind}
|
|
23
|
+
/*
|
|
24
|
+
* A deleted account is not on anybody's list. The row survives by
|
|
25
|
+
* design — deleted_at is a tombstone, not a removal — but showing
|
|
26
|
+
* a name the board no longer recognises anywhere else would be a
|
|
27
|
+
* list somebody cannot act on.
|
|
28
|
+
*/
|
|
29
|
+
and u.deleted_at is null
|
|
30
|
+
order by u.username
|
|
31
|
+
`),
|
|
32
|
+
) as Array<{
|
|
33
|
+
other_user_id: number
|
|
34
|
+
kind: string
|
|
35
|
+
created_at: string | Date
|
|
36
|
+
username: string
|
|
37
|
+
last_active_at: string | Date | null
|
|
38
|
+
}>
|
|
39
|
+
|
|
40
|
+
return rows.map((row) => ({
|
|
41
|
+
userId: Number(row.other_user_id),
|
|
42
|
+
username: row.username,
|
|
43
|
+
kind: row.kind as RelationKind,
|
|
44
|
+
lastActiveAt: toNullableDate(row.last_active_at),
|
|
45
|
+
createdAt: toDate(row.created_at),
|
|
46
|
+
}))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async count(userId: number): Promise<number> {
|
|
50
|
+
const rows = resultRows(
|
|
51
|
+
await this.db.execute(sql`
|
|
52
|
+
select count(*)::int as n from user_relations where user_id = ${userId}
|
|
53
|
+
`),
|
|
54
|
+
) as Array<{ n: number }>
|
|
55
|
+
|
|
56
|
+
return Number(rows[0]?.n ?? 0)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async ignoredIds(userId: number): Promise<readonly number[]> {
|
|
60
|
+
const rows = resultRows(
|
|
61
|
+
await this.db.execute(sql`
|
|
62
|
+
select other_user_id from user_relations
|
|
63
|
+
where user_id = ${userId} and kind = 'ignore'
|
|
64
|
+
`),
|
|
65
|
+
) as Array<{ other_user_id: number }>
|
|
66
|
+
|
|
67
|
+
return rows.map((row) => Number(row.other_user_id))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async set(input: {
|
|
71
|
+
readonly userId: number
|
|
72
|
+
readonly otherUserId: number
|
|
73
|
+
readonly kind: RelationKind
|
|
74
|
+
readonly at: Date
|
|
75
|
+
}): Promise<void> {
|
|
76
|
+
await this.db.execute(sql`
|
|
77
|
+
insert into user_relations (user_id, other_user_id, kind, created_at)
|
|
78
|
+
values (${input.userId}, ${input.otherUserId}, ${input.kind}, ${input.at})
|
|
79
|
+
on conflict (user_id, other_user_id)
|
|
80
|
+
/*
|
|
81
|
+
* The kind is replaced; created_at is not. "On my list since" is a
|
|
82
|
+
* property of the relationship, not of the last time somebody changed
|
|
83
|
+
* their mind about which list it belongs on.
|
|
84
|
+
*/
|
|
85
|
+
do update set kind = excluded.kind
|
|
86
|
+
`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async remove(input: { readonly userId: number; readonly otherUserId: number }): Promise<boolean> {
|
|
90
|
+
const rows = resultRows(
|
|
91
|
+
await this.db.execute(sql`
|
|
92
|
+
delete from user_relations
|
|
93
|
+
where user_id = ${input.userId} and other_user_id = ${input.otherUserId}
|
|
94
|
+
returning user_id
|
|
95
|
+
`),
|
|
96
|
+
) as Array<{ user_id: number }>
|
|
97
|
+
|
|
98
|
+
return rows.length > 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async ignores(ownerUserId: number, otherUserId: number): Promise<boolean> {
|
|
102
|
+
const rows = resultRows(
|
|
103
|
+
await this.db.execute(sql`
|
|
104
|
+
select 1 as hit from user_relations
|
|
105
|
+
where user_id = ${ownerUserId}
|
|
106
|
+
and other_user_id = ${otherUserId}
|
|
107
|
+
and kind = 'ignore'
|
|
108
|
+
`),
|
|
109
|
+
) as Array<{ hit: number }>
|
|
110
|
+
|
|
111
|
+
return rows.length > 0
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
authorRef,
|
|
5
|
+
BodyFormat,
|
|
6
|
+
CORE_RENDERING,
|
|
7
|
+
type MarkdownPipeline,
|
|
8
|
+
RENDER_VERSION,
|
|
9
|
+
renderThrough,
|
|
10
|
+
sourceAsMarkdown,
|
|
11
|
+
vocabularyOptions,
|
|
12
|
+
} from '@meith/markdown'
|
|
13
|
+
|
|
14
|
+
import type { Database } from './client'
|
|
15
|
+
import { resultRows } from './result-rows'
|
|
16
|
+
import { readBoardVocabulary } from './vocabulary-repo'
|
|
17
|
+
|
|
18
|
+
export interface RenderBackfillRun {
|
|
19
|
+
readonly rendered: number
|
|
20
|
+
readonly converted: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class PostgresRenderBackfill {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly db: Database,
|
|
26
|
+
private readonly rendering: MarkdownPipeline = CORE_RENDERING,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
async pending(): Promise<number> {
|
|
30
|
+
const vocabulary = await readBoardVocabulary(this.db, this.rendering)
|
|
31
|
+
const rows = resultRows(
|
|
32
|
+
await this.db.execute(sql`
|
|
33
|
+
select count(*)::int as pending
|
|
34
|
+
from posts
|
|
35
|
+
where render_version <> ${RENDER_VERSION}
|
|
36
|
+
or vocab_version <> ${vocabulary.revision}
|
|
37
|
+
or body_format <> ${BodyFormat.Markdown}
|
|
38
|
+
`),
|
|
39
|
+
) as Array<{ pending: number }>
|
|
40
|
+
return Number(rows[0]?.pending ?? 0)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async run(batchSize: number): Promise<RenderBackfillRun> {
|
|
44
|
+
if (batchSize <= 0) return { rendered: 0, converted: 0 }
|
|
45
|
+
|
|
46
|
+
const vocabulary = await readBoardVocabulary(this.db, this.rendering)
|
|
47
|
+
|
|
48
|
+
const stale = resultRows(
|
|
49
|
+
await this.db.execute(sql`
|
|
50
|
+
select id, message, body_format, author_user_id
|
|
51
|
+
from posts
|
|
52
|
+
where render_version <> ${RENDER_VERSION}
|
|
53
|
+
or vocab_version <> ${vocabulary.revision}
|
|
54
|
+
or body_format <> ${BodyFormat.Markdown}
|
|
55
|
+
order by id
|
|
56
|
+
limit ${batchSize}
|
|
57
|
+
`),
|
|
58
|
+
) as Array<{
|
|
59
|
+
id: number
|
|
60
|
+
message: string
|
|
61
|
+
body_format: number
|
|
62
|
+
author_user_id: number | null
|
|
63
|
+
}>
|
|
64
|
+
|
|
65
|
+
if (stale.length === 0) return { rendered: 0, converted: 0 }
|
|
66
|
+
|
|
67
|
+
const rendered = await Promise.all(
|
|
68
|
+
stale.map(async (row) => {
|
|
69
|
+
const format = Number(row.body_format)
|
|
70
|
+
const message = sourceAsMarkdown(row.message, format)
|
|
71
|
+
const body = await renderThrough(
|
|
72
|
+
this.rendering,
|
|
73
|
+
message,
|
|
74
|
+
{
|
|
75
|
+
source: 'post',
|
|
76
|
+
viewer: authorRef(row.author_user_id === null ? null : Number(row.author_user_id)),
|
|
77
|
+
postId: Number(row.id),
|
|
78
|
+
},
|
|
79
|
+
vocabularyOptions(vocabulary),
|
|
80
|
+
)
|
|
81
|
+
return {
|
|
82
|
+
id: Number(row.id),
|
|
83
|
+
message,
|
|
84
|
+
html: body.html,
|
|
85
|
+
converted: format !== BodyFormat.Markdown,
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const values = sql.join(
|
|
91
|
+
rendered.map((row) => sql`(${row.id}::int, ${row.message}::text, ${row.html}::text)`),
|
|
92
|
+
sql`, `,
|
|
93
|
+
)
|
|
94
|
+
await this.db.execute(sql`
|
|
95
|
+
update posts as p
|
|
96
|
+
set message = v.message,
|
|
97
|
+
message_html = v.html,
|
|
98
|
+
render_version = ${RENDER_VERSION},
|
|
99
|
+
vocab_version = ${vocabulary.revision},
|
|
100
|
+
body_format = ${BodyFormat.Markdown}
|
|
101
|
+
from (values ${values}) as v(id, message, html)
|
|
102
|
+
where p.id = v.id
|
|
103
|
+
`)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
rendered: rendered.length,
|
|
107
|
+
converted: rendered.filter((row) => row.converted).length,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
import { resultRows } from './result-rows'
|
|
5
|
+
|
|
6
|
+
const SIGNATURE_KEY = 'plugin_render'
|
|
7
|
+
const VOCABULARY_KEY = 'markdown_vocabulary'
|
|
8
|
+
|
|
9
|
+
export async function syncRenderSignature(db: Database, signature: number): Promise<boolean> {
|
|
10
|
+
return db.transaction(async (tx) => {
|
|
11
|
+
const rows = resultRows(
|
|
12
|
+
await tx.execute(sql`select version from cache_versions where key = ${SIGNATURE_KEY}`),
|
|
13
|
+
) as Array<{ version: number }>
|
|
14
|
+
|
|
15
|
+
const recorded = rows[0] === undefined ? null : Number(rows[0].version)
|
|
16
|
+
if (recorded === signature) return false
|
|
17
|
+
|
|
18
|
+
await tx.execute(sql`
|
|
19
|
+
insert into cache_versions (key, version) values (${SIGNATURE_KEY}, ${signature})
|
|
20
|
+
on conflict (key) do update set version = ${signature}, bumped_at = now()
|
|
21
|
+
`)
|
|
22
|
+
|
|
23
|
+
if (recorded === null) return false
|
|
24
|
+
|
|
25
|
+
await tx.execute(sql`
|
|
26
|
+
insert into cache_versions (key, version) values (${VOCABULARY_KEY}, 1)
|
|
27
|
+
on conflict (key)
|
|
28
|
+
do update set version = cache_versions.version + 1, bumped_at = now()
|
|
29
|
+
`)
|
|
30
|
+
|
|
31
|
+
return true
|
|
32
|
+
})
|
|
33
|
+
}
|