@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,80 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
import { resultRows } from './result-rows'
|
|
5
|
+
|
|
6
|
+
export interface PluginHealthRecord {
|
|
7
|
+
readonly pluginKey: string
|
|
8
|
+
readonly failures: number
|
|
9
|
+
readonly disabledAt: Date | null
|
|
10
|
+
readonly reason: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toRecord(row: Record<string, unknown>): PluginHealthRecord {
|
|
14
|
+
return {
|
|
15
|
+
pluginKey: String(row.plugin_key),
|
|
16
|
+
failures: Number(row.failures),
|
|
17
|
+
disabledAt: row.disabled_at === null ? null : new Date(row.disabled_at as string),
|
|
18
|
+
reason: row.reason === null ? null : String(row.reason),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readPluginHealth(db: Database): Promise<readonly PluginHealthRecord[]> {
|
|
23
|
+
const rows = resultRows(
|
|
24
|
+
await db.execute(sql`
|
|
25
|
+
select plugin_key, failures, disabled_at, reason
|
|
26
|
+
from plugin_health
|
|
27
|
+
order by plugin_key
|
|
28
|
+
`),
|
|
29
|
+
) as Array<Record<string, unknown>>
|
|
30
|
+
|
|
31
|
+
return rows.map(toRecord)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function recordPluginFailure(
|
|
35
|
+
db: Database,
|
|
36
|
+
input: {
|
|
37
|
+
readonly pluginKey: string
|
|
38
|
+
readonly threshold: number
|
|
39
|
+
readonly reason: string
|
|
40
|
+
},
|
|
41
|
+
): Promise<PluginHealthRecord> {
|
|
42
|
+
const rows = resultRows(
|
|
43
|
+
await db.execute(sql`
|
|
44
|
+
insert into plugin_health (plugin_key, failures, disabled_at, reason, updated_at)
|
|
45
|
+
values (
|
|
46
|
+
${input.pluginKey},
|
|
47
|
+
1,
|
|
48
|
+
case when 1 >= ${input.threshold} then now() else null end,
|
|
49
|
+
case when 1 >= ${input.threshold} then ${input.reason} else null end,
|
|
50
|
+
now()
|
|
51
|
+
)
|
|
52
|
+
on conflict (plugin_key) do update
|
|
53
|
+
set failures = plugin_health.failures + 1,
|
|
54
|
+
disabled_at = case
|
|
55
|
+
when plugin_health.disabled_at is not null then plugin_health.disabled_at
|
|
56
|
+
when plugin_health.failures + 1 >= ${input.threshold} then now()
|
|
57
|
+
else null
|
|
58
|
+
end,
|
|
59
|
+
reason = case
|
|
60
|
+
when plugin_health.disabled_at is not null then plugin_health.reason
|
|
61
|
+
when plugin_health.failures + 1 >= ${input.threshold} then ${input.reason}
|
|
62
|
+
else null
|
|
63
|
+
end,
|
|
64
|
+
updated_at = now()
|
|
65
|
+
returning plugin_key, failures, disabled_at, reason
|
|
66
|
+
`),
|
|
67
|
+
) as Array<Record<string, unknown>>
|
|
68
|
+
|
|
69
|
+
return toRecord(rows[0]!)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function clearPluginHealth(db: Database, pluginKey: string): Promise<boolean> {
|
|
73
|
+
const rows = resultRows(
|
|
74
|
+
await db.execute(sql`
|
|
75
|
+
delete from plugin_health where plugin_key = ${pluginKey} returning plugin_key
|
|
76
|
+
`),
|
|
77
|
+
) as Array<Record<string, unknown>>
|
|
78
|
+
|
|
79
|
+
return rows.length > 0
|
|
80
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { pluginTablePrefix } from '@meith/plugin-kit'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
|
|
8
|
+
export interface PluginPurgeResult {
|
|
9
|
+
readonly tables: readonly string[]
|
|
10
|
+
readonly settings: number
|
|
11
|
+
readonly migrations: number
|
|
12
|
+
readonly navigation: number
|
|
13
|
+
readonly versionRemoved: boolean
|
|
14
|
+
readonly healthRemoved: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function pluginOwnedTables(
|
|
18
|
+
db: Database,
|
|
19
|
+
pluginKey: string,
|
|
20
|
+
): Promise<readonly string[]> {
|
|
21
|
+
const rows = resultRows(
|
|
22
|
+
await db.execute(sql`
|
|
23
|
+
select table_name
|
|
24
|
+
from information_schema.tables
|
|
25
|
+
where table_schema = 'public'
|
|
26
|
+
and table_type = 'BASE TABLE'
|
|
27
|
+
and table_name like ${`${pluginTablePrefix(pluginKey)}%`}
|
|
28
|
+
order by table_name
|
|
29
|
+
`),
|
|
30
|
+
) as Array<{ table_name: string }>
|
|
31
|
+
|
|
32
|
+
return rows.map((row) => String(row.table_name))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Everything a plugin left behind, taken away in one transaction.
|
|
37
|
+
*
|
|
38
|
+
* The tables are found by prefix rather than from the plugin's own migration
|
|
39
|
+
* list, because the list says what it meant to create and the schema says what
|
|
40
|
+
* it did. The prefix is safe to trust: `definePlugin` refuses a migration that
|
|
41
|
+
* names anything outside it, so nothing the board owns can match.
|
|
42
|
+
*/
|
|
43
|
+
export async function purgePlugin(db: Database, pluginKey: string): Promise<PluginPurgeResult> {
|
|
44
|
+
const tables = await pluginOwnedTables(db, pluginKey)
|
|
45
|
+
|
|
46
|
+
return db.transaction(async (tx) => {
|
|
47
|
+
for (const table of tables) {
|
|
48
|
+
await tx.execute(sql.raw(`drop table if exists "${table}" cascade`))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const settings = resultRows(
|
|
52
|
+
await tx.execute(sql`
|
|
53
|
+
delete from settings where key like ${`plugin.${pluginKey}.%`} returning key
|
|
54
|
+
`),
|
|
55
|
+
) as Array<unknown>
|
|
56
|
+
|
|
57
|
+
const migrations = resultRows(
|
|
58
|
+
await tx.execute(sql`
|
|
59
|
+
delete from plugin_migrations where plugin_key = ${pluginKey} returning migration_id
|
|
60
|
+
`),
|
|
61
|
+
) as Array<unknown>
|
|
62
|
+
|
|
63
|
+
const navigation = resultRows(
|
|
64
|
+
await tx.execute(sql`
|
|
65
|
+
delete from navigation_items where key like ${`plugin.${pluginKey}.%`} returning id
|
|
66
|
+
`),
|
|
67
|
+
) as Array<unknown>
|
|
68
|
+
|
|
69
|
+
const version = resultRows(
|
|
70
|
+
await tx.execute(sql`
|
|
71
|
+
delete from component_versions where component = ${`plugin:${pluginKey}`}
|
|
72
|
+
returning component
|
|
73
|
+
`),
|
|
74
|
+
) as Array<unknown>
|
|
75
|
+
|
|
76
|
+
const health = resultRows(
|
|
77
|
+
await tx.execute(sql`
|
|
78
|
+
delete from plugin_health where plugin_key = ${pluginKey} returning plugin_key
|
|
79
|
+
`),
|
|
80
|
+
) as Array<unknown>
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
tables,
|
|
84
|
+
settings: settings.length,
|
|
85
|
+
migrations: migrations.length,
|
|
86
|
+
navigation: navigation.length,
|
|
87
|
+
versionRemoved: version.length > 0,
|
|
88
|
+
healthRemoved: health.length > 0,
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { and, eq, ne } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { PluginUserRef, PluginUsers } from '@meith/plugin-kit'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { users } from './schema'
|
|
7
|
+
|
|
8
|
+
const REF = { userId: users.id, username: users.username } as const
|
|
9
|
+
|
|
10
|
+
export function pluginUsers(db: Database): PluginUsers {
|
|
11
|
+
const first = (rows: readonly PluginUserRef[]): PluginUserRef | null => rows[0] ?? null
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
async byUsername(username) {
|
|
15
|
+
const needle = username.trim().toLowerCase()
|
|
16
|
+
if (needle === '') return null
|
|
17
|
+
|
|
18
|
+
return first(
|
|
19
|
+
await db
|
|
20
|
+
.select(REF)
|
|
21
|
+
.from(users)
|
|
22
|
+
.where(and(eq(users.usernameLower, needle), ne(users.state, 'deleted')))
|
|
23
|
+
.limit(1),
|
|
24
|
+
)
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async byId(userId) {
|
|
28
|
+
if (!Number.isSafeInteger(userId) || userId <= 0) return null
|
|
29
|
+
|
|
30
|
+
return first(
|
|
31
|
+
await db
|
|
32
|
+
.select(REF)
|
|
33
|
+
.from(users)
|
|
34
|
+
.where(and(eq(users.id, userId), ne(users.state, 'deleted')))
|
|
35
|
+
.limit(1),
|
|
36
|
+
)
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/poll-repo.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { type SQL, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
POLL_CHOICES_UNLIMITED,
|
|
5
|
+
POLL_VOTERS_SHOWN_MAX,
|
|
6
|
+
type Poll,
|
|
7
|
+
type PollEditPlan,
|
|
8
|
+
type PollRepository,
|
|
9
|
+
type PollVote,
|
|
10
|
+
type PollVoter,
|
|
11
|
+
type ThreadRating,
|
|
12
|
+
type ThreadRatingRepository,
|
|
13
|
+
type ValidatedPoll,
|
|
14
|
+
} from '@meith/polls'
|
|
15
|
+
|
|
16
|
+
import type { Database } from './client'
|
|
17
|
+
import { resultRows } from './result-rows'
|
|
18
|
+
|
|
19
|
+
function date(value: Date | string | null): Date | null {
|
|
20
|
+
return value === null ? null : value instanceof Date ? value : new Date(value)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ids(values: readonly number[]): SQL {
|
|
24
|
+
return sql.join(
|
|
25
|
+
values.map((value) => sql`${value}`),
|
|
26
|
+
sql`, `,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class PostgresPollRepository implements PollRepository, ThreadRatingRepository {
|
|
31
|
+
constructor(private readonly db: Database) {}
|
|
32
|
+
|
|
33
|
+
async create(threadId: number, poll: ValidatedPoll): Promise<void> {
|
|
34
|
+
await this.db.transaction(async (tx) => {
|
|
35
|
+
const rows = resultRows(
|
|
36
|
+
await tx.execute(sql`
|
|
37
|
+
insert into polls (thread_id, question, closes_at, max_options, allow_revote, public_votes)
|
|
38
|
+
values (${threadId}, ${poll.question}, ${poll.closesAt}, ${poll.maxOptions},
|
|
39
|
+
${poll.allowRevote}, ${poll.publicVotes}) returning id
|
|
40
|
+
`),
|
|
41
|
+
) as Array<{ id: number }>
|
|
42
|
+
const id = rows[0]?.id
|
|
43
|
+
if (id === undefined) throw new Error('Poll insert returned no row.')
|
|
44
|
+
await tx.execute(sql`
|
|
45
|
+
insert into poll_options (poll_id, label, display_order)
|
|
46
|
+
values ${sql.join(
|
|
47
|
+
poll.options.map((label, index) => sql`(${id}, ${label}, ${index})`),
|
|
48
|
+
sql`, `,
|
|
49
|
+
)}
|
|
50
|
+
`)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async find(threadId: number, voterUserId: number | null): Promise<Poll | null> {
|
|
55
|
+
const polls = resultRows(
|
|
56
|
+
await this.db.execute(sql`
|
|
57
|
+
select id, question, closes_at, created_at, max_options, allow_revote, public_votes
|
|
58
|
+
from polls where thread_id = ${threadId}
|
|
59
|
+
`),
|
|
60
|
+
) as Array<{
|
|
61
|
+
id: number
|
|
62
|
+
question: string
|
|
63
|
+
closes_at: Date | string | null
|
|
64
|
+
created_at: Date | string
|
|
65
|
+
max_options: number
|
|
66
|
+
allow_revote: boolean
|
|
67
|
+
public_votes: boolean
|
|
68
|
+
}>
|
|
69
|
+
const poll = polls[0]
|
|
70
|
+
if (poll === undefined) return null
|
|
71
|
+
|
|
72
|
+
const [options, votes, voters] = await Promise.all([
|
|
73
|
+
this.db.execute(sql`
|
|
74
|
+
select id, label, vote_count from poll_options
|
|
75
|
+
where poll_id = ${poll.id} order by display_order, id
|
|
76
|
+
`),
|
|
77
|
+
voterUserId === null
|
|
78
|
+
? Promise.resolve([])
|
|
79
|
+
: this.db.execute(sql`
|
|
80
|
+
select option_id from poll_votes
|
|
81
|
+
where poll_id = ${poll.id} and user_id = ${voterUserId}
|
|
82
|
+
`),
|
|
83
|
+
poll.public_votes ? this.voters(Number(poll.id)) : Promise.resolve(new Map()),
|
|
84
|
+
])
|
|
85
|
+
const voteRows = resultRows(votes) as Array<{ option_id: number }>
|
|
86
|
+
const byOption = voters as Map<number, PollVoter[]>
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: Number(poll.id),
|
|
90
|
+
threadId,
|
|
91
|
+
question: poll.question,
|
|
92
|
+
closesAt: date(poll.closes_at),
|
|
93
|
+
createdAt: date(poll.created_at) ?? new Date(0),
|
|
94
|
+
maxOptions: Number(poll.max_options),
|
|
95
|
+
allowRevote: poll.allow_revote === true,
|
|
96
|
+
publicVotes: poll.public_votes === true,
|
|
97
|
+
options: (
|
|
98
|
+
resultRows(options) as Array<{
|
|
99
|
+
id: number
|
|
100
|
+
label: string
|
|
101
|
+
vote_count: number
|
|
102
|
+
}>
|
|
103
|
+
).map((row) => ({
|
|
104
|
+
id: Number(row.id),
|
|
105
|
+
label: row.label,
|
|
106
|
+
votes: Number(row.vote_count),
|
|
107
|
+
voters: byOption.get(Number(row.id)) ?? [],
|
|
108
|
+
})),
|
|
109
|
+
votedOptionIds: voteRows.map((row) => Number(row.option_id)),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async voters(pollId: number): Promise<Map<number, PollVoter[]>> {
|
|
114
|
+
const rows = resultRows(
|
|
115
|
+
await this.db.execute(sql`
|
|
116
|
+
select o.id as option_id, v.user_id, u.username, v.created_at
|
|
117
|
+
from poll_options o
|
|
118
|
+
join lateral (
|
|
119
|
+
select pv.user_id, pv.created_at from poll_votes pv
|
|
120
|
+
where pv.poll_id = o.poll_id and pv.option_id = o.id
|
|
121
|
+
order by pv.created_at, pv.user_id limit ${POLL_VOTERS_SHOWN_MAX}
|
|
122
|
+
) v on true
|
|
123
|
+
join users u on u.id = v.user_id
|
|
124
|
+
where o.poll_id = ${pollId}
|
|
125
|
+
order by o.display_order, o.id, v.created_at, v.user_id
|
|
126
|
+
`),
|
|
127
|
+
) as Array<{
|
|
128
|
+
option_id: number
|
|
129
|
+
user_id: number
|
|
130
|
+
username: string
|
|
131
|
+
created_at: Date | string | null
|
|
132
|
+
}>
|
|
133
|
+
|
|
134
|
+
const byOption = new Map<number, PollVoter[]>()
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
const optionId = Number(row.option_id)
|
|
137
|
+
const list = byOption.get(optionId) ?? []
|
|
138
|
+
list.push({
|
|
139
|
+
userId: Number(row.user_id),
|
|
140
|
+
username: row.username,
|
|
141
|
+
votedAt: date(row.created_at),
|
|
142
|
+
})
|
|
143
|
+
byOption.set(optionId, list)
|
|
144
|
+
}
|
|
145
|
+
return byOption
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async vote(input: PollVote): Promise<boolean> {
|
|
149
|
+
const wanted = [...new Set(input.optionIds)]
|
|
150
|
+
if (wanted.length === 0) return false
|
|
151
|
+
|
|
152
|
+
return this.db.transaction(async (tx) => {
|
|
153
|
+
const polls = resultRows(
|
|
154
|
+
await tx.execute(sql`
|
|
155
|
+
select id, max_options, allow_revote from polls
|
|
156
|
+
where id = ${input.pollId} and thread_id = ${input.threadId}
|
|
157
|
+
and (closes_at is null or closes_at > now())
|
|
158
|
+
for update
|
|
159
|
+
`),
|
|
160
|
+
) as Array<{ id: number; max_options: number; allow_revote: boolean }>
|
|
161
|
+
const poll = polls[0]
|
|
162
|
+
if (poll === undefined) return false
|
|
163
|
+
|
|
164
|
+
const maxOptions = Number(poll.max_options)
|
|
165
|
+
if (maxOptions !== POLL_CHOICES_UNLIMITED && wanted.length > maxOptions) return false
|
|
166
|
+
|
|
167
|
+
const chosen = resultRows(
|
|
168
|
+
await tx.execute(sql`
|
|
169
|
+
select id from poll_options
|
|
170
|
+
where poll_id = ${poll.id} and id in (${ids(wanted)})
|
|
171
|
+
`),
|
|
172
|
+
)
|
|
173
|
+
if (chosen.length !== wanted.length) return false
|
|
174
|
+
|
|
175
|
+
const previous = resultRows(
|
|
176
|
+
await tx.execute(sql`
|
|
177
|
+
select option_id from poll_votes
|
|
178
|
+
where poll_id = ${poll.id} and user_id = ${input.userId}
|
|
179
|
+
`),
|
|
180
|
+
) as Array<{ option_id: number }>
|
|
181
|
+
|
|
182
|
+
if (previous.length > 0) {
|
|
183
|
+
if (poll.allow_revote !== true) return false
|
|
184
|
+
await tx.execute(sql`
|
|
185
|
+
delete from poll_votes where poll_id = ${poll.id} and user_id = ${input.userId}
|
|
186
|
+
`)
|
|
187
|
+
await tx.execute(sql`
|
|
188
|
+
update poll_options set vote_count = greatest(0, vote_count - 1)
|
|
189
|
+
where poll_id = ${poll.id}
|
|
190
|
+
and id in (${ids(previous.map((row) => Number(row.option_id)))})
|
|
191
|
+
`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await tx.execute(sql`
|
|
195
|
+
insert into poll_votes (poll_id, user_id, option_id)
|
|
196
|
+
values ${sql.join(
|
|
197
|
+
wanted.map((optionId) => sql`(${poll.id}, ${input.userId}, ${optionId})`),
|
|
198
|
+
sql`, `,
|
|
199
|
+
)}
|
|
200
|
+
`)
|
|
201
|
+
await tx.execute(sql`
|
|
202
|
+
update poll_options set vote_count = vote_count + 1
|
|
203
|
+
where poll_id = ${poll.id} and id in (${ids(wanted)})
|
|
204
|
+
`)
|
|
205
|
+
return true
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async applyEdit(plan: PollEditPlan): Promise<boolean> {
|
|
210
|
+
return this.db.transaction(async (tx) => {
|
|
211
|
+
const polls = resultRows(
|
|
212
|
+
await tx.execute(sql`
|
|
213
|
+
select id, public_votes from polls where id = ${plan.pollId} for update
|
|
214
|
+
`),
|
|
215
|
+
) as Array<{ id: number; public_votes: boolean }>
|
|
216
|
+
const poll = polls[0]
|
|
217
|
+
if (poll === undefined) return false
|
|
218
|
+
|
|
219
|
+
const kept = plan.options.map((option) => option.id).filter((id): id is number => id !== null)
|
|
220
|
+
const known = resultRows(
|
|
221
|
+
await tx.execute(sql`
|
|
222
|
+
select id from poll_options where poll_id = ${poll.id}
|
|
223
|
+
`),
|
|
224
|
+
) as Array<{ id: number }>
|
|
225
|
+
const existing = new Set(known.map((row) => Number(row.id)))
|
|
226
|
+
if (kept.some((id) => !existing.has(id))) return false
|
|
227
|
+
|
|
228
|
+
const cast = resultRows(
|
|
229
|
+
await tx.execute(sql`
|
|
230
|
+
select option_id from poll_votes where poll_id = ${poll.id}
|
|
231
|
+
`),
|
|
232
|
+
) as Array<{ option_id: number }>
|
|
233
|
+
|
|
234
|
+
const removed = [...existing].filter((id) => !kept.includes(id))
|
|
235
|
+
if (removed.some((id) => cast.some((row) => Number(row.option_id) === id))) return false
|
|
236
|
+
if (plan.publicVotes && poll.public_votes !== true && cast.length > 0) return false
|
|
237
|
+
|
|
238
|
+
if (removed.length > 0) {
|
|
239
|
+
await tx.execute(sql`
|
|
240
|
+
delete from poll_options where poll_id = ${poll.id} and id in (${ids(removed)})
|
|
241
|
+
`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (kept.length > 0) {
|
|
245
|
+
await tx.execute(sql`
|
|
246
|
+
update poll_options set display_order = -1 - display_order where poll_id = ${poll.id}
|
|
247
|
+
`)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let order = 0
|
|
251
|
+
for (const option of plan.options) {
|
|
252
|
+
if (option.id === null) {
|
|
253
|
+
await tx.execute(sql`
|
|
254
|
+
insert into poll_options (poll_id, label, display_order)
|
|
255
|
+
values (${poll.id}, ${option.label}, ${order})
|
|
256
|
+
`)
|
|
257
|
+
} else {
|
|
258
|
+
await tx.execute(sql`
|
|
259
|
+
update poll_options set label = ${option.label}, display_order = ${order}
|
|
260
|
+
where poll_id = ${poll.id} and id = ${option.id}
|
|
261
|
+
`)
|
|
262
|
+
}
|
|
263
|
+
order += 1
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await tx.execute(sql`
|
|
267
|
+
update polls set question = ${plan.question}, closes_at = ${plan.closesAt},
|
|
268
|
+
max_options = ${plan.maxOptions}, allow_revote = ${plan.allowRevote},
|
|
269
|
+
public_votes = ${plan.publicVotes}
|
|
270
|
+
where id = ${poll.id}
|
|
271
|
+
`)
|
|
272
|
+
return true
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async rate(input: {
|
|
277
|
+
readonly threadId: number
|
|
278
|
+
readonly userId: number
|
|
279
|
+
readonly rating: number
|
|
280
|
+
}): Promise<ThreadRating | null> {
|
|
281
|
+
return this.db.transaction(async (tx) => {
|
|
282
|
+
const locked = resultRows(
|
|
283
|
+
await tx.execute(sql`select id from threads where id = ${input.threadId} for update`),
|
|
284
|
+
)
|
|
285
|
+
if (locked.length === 0) return null
|
|
286
|
+
const previous = resultRows(
|
|
287
|
+
await tx.execute(sql`
|
|
288
|
+
select rating from thread_ratings where thread_id = ${input.threadId} and user_id = ${input.userId}
|
|
289
|
+
`),
|
|
290
|
+
) as Array<{ rating: number }>
|
|
291
|
+
await tx.execute(sql`
|
|
292
|
+
insert into thread_ratings (thread_id, user_id, rating, updated_at)
|
|
293
|
+
values (${input.threadId}, ${input.userId}, ${input.rating}, now())
|
|
294
|
+
on conflict (thread_id, user_id) do update set rating = excluded.rating, updated_at = excluded.updated_at
|
|
295
|
+
`)
|
|
296
|
+
const prior = Number(previous[0]?.rating ?? 0)
|
|
297
|
+
const rows = resultRows(
|
|
298
|
+
await tx.execute(sql`
|
|
299
|
+
update threads set rating_total = rating_total + ${input.rating - prior},
|
|
300
|
+
rating_count = rating_count + ${previous.length === 0 ? 1 : 0}
|
|
301
|
+
where id = ${input.threadId}
|
|
302
|
+
returning rating_total, rating_count
|
|
303
|
+
`),
|
|
304
|
+
) as Array<{ rating_total: number; rating_count: number }>
|
|
305
|
+
const row = rows[0]!
|
|
306
|
+
return {
|
|
307
|
+
average: Number(row.rating_total) / Number(row.rating_count),
|
|
308
|
+
count: Number(row.rating_count),
|
|
309
|
+
mine: input.rating,
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async findRating(threadId: number, userId: number | null): Promise<ThreadRating | null> {
|
|
315
|
+
const rows = resultRows(
|
|
316
|
+
await this.db.execute(sql`
|
|
317
|
+
select t.rating_total, t.rating_count, r.rating as mine
|
|
318
|
+
from threads t left join thread_ratings r on r.thread_id = t.id and r.user_id = ${userId}
|
|
319
|
+
where t.id = ${threadId}
|
|
320
|
+
`),
|
|
321
|
+
) as Array<{
|
|
322
|
+
rating_total: number
|
|
323
|
+
rating_count: number
|
|
324
|
+
mine: number | null
|
|
325
|
+
}>
|
|
326
|
+
const row = rows[0]
|
|
327
|
+
if (row === undefined) return null
|
|
328
|
+
return {
|
|
329
|
+
average: row.rating_count === 0 ? 0 : Number(row.rating_total) / Number(row.rating_count),
|
|
330
|
+
count: Number(row.rating_count),
|
|
331
|
+
mine: row.mine === null ? null : Number(row.mine),
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|