@meith/db 0.31.0 → 0.32.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/migrations/0060_webhook_payload_format.sql +1 -0
- package/migrations/0061_task_schedule.sql +1 -0
- package/migrations/0062_pretty_zombie.sql +12 -0
- package/migrations/0063_board_digest.sql +3 -0
- package/migrations/0064_redundant_lady_bullseye.sql +2 -0
- package/migrations/0065_serious_puff_adder.sql +1 -0
- package/migrations/meta/0061_snapshot.json +9515 -0
- package/migrations/meta/0062_snapshot.json +9617 -0
- package/migrations/meta/0063_snapshot.json +9653 -0
- package/migrations/meta/0064_snapshot.json +9667 -0
- package/migrations/meta/0065_snapshot.json +9674 -0
- package/migrations/meta/_journal.json +42 -0
- package/package.json +31 -30
- package/src/account-repos.ts +32 -15
- package/src/api-repo.ts +192 -21
- package/src/attachment-repo.ts +26 -0
- package/src/board-digest-repo.ts +45 -0
- package/src/content-counters.ts +14 -0
- package/src/discovery-repo.ts +22 -0
- package/src/draft-repo.ts +40 -1
- package/src/feed-token-repo.ts +103 -0
- package/src/forum-admin-repo.ts +43 -18
- package/src/index.ts +10 -0
- package/src/member-settings-repo.ts +25 -1
- package/src/moderation-queue.ts +61 -21
- package/src/plugin-data.ts +19 -0
- package/src/plugin-grants.ts +83 -0
- package/src/plugin-role.ts +79 -0
- package/src/post-repo.ts +26 -0
- package/src/post-writes.ts +32 -2
- package/src/presence-repo.ts +29 -1
- package/src/read-state-repo.ts +21 -1
- package/src/report-repo.ts +151 -7
- package/src/schema/content.ts +5 -0
- package/src/schema/identity.ts +33 -1
- package/src/schema/platform.ts +2 -0
- package/src/search-repo.ts +48 -17
- package/src/task-repo.ts +19 -7
- package/src/thread-writes.ts +7 -5
- package/src/upgrade-repo.ts +15 -7
- package/src/user-merge-map.ts +1 -0
package/src/post-writes.ts
CHANGED
|
@@ -17,7 +17,12 @@ import type {
|
|
|
17
17
|
|
|
18
18
|
import type { Database } from './client'
|
|
19
19
|
import { resultRows } from './result-rows'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
indexedSubjectSql,
|
|
22
|
+
readSearchConfig,
|
|
23
|
+
SEARCH_DOCUMENT_VERSION,
|
|
24
|
+
searchVectorSql,
|
|
25
|
+
} from './search-repo'
|
|
21
26
|
import { logModeratorAction } from './thread-counters'
|
|
22
27
|
import { applyVisibilityChangeCounters } from './visibility-counters'
|
|
23
28
|
import { readBoardVocabulary } from './vocabulary-repo'
|
|
@@ -116,6 +121,7 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
|
|
|
116
121
|
{ source: 'post', viewer: authorRef(record.editedByUserId), postId: record.postId },
|
|
117
122
|
vocabularyOptions(vocabulary),
|
|
118
123
|
)
|
|
124
|
+
const searchConfig = await readSearchConfig(this.db)
|
|
119
125
|
|
|
120
126
|
await this.db.transaction(async (tx) => {
|
|
121
127
|
await tx.execute(sql`
|
|
@@ -148,7 +154,7 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
|
|
|
148
154
|
* edited opening post lost the title an edit is supposed to
|
|
149
155
|
* preserve. The alias is why the statement names "posts p".
|
|
150
156
|
*/
|
|
151
|
-
search_vector = ${searchVectorSql(indexedSubjectSql(sql`p`), sql`${record.message}`)},
|
|
157
|
+
search_vector = ${searchVectorSql(searchConfig, indexedSubjectSql(sql`p`), sql`${record.message}`)},
|
|
152
158
|
search_version = ${SEARCH_DOCUMENT_VERSION},
|
|
153
159
|
render_version = ${body.version},
|
|
154
160
|
vocab_version = ${vocabulary.revision},
|
|
@@ -187,6 +193,16 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
|
|
|
187
193
|
record.editedAt,
|
|
188
194
|
)
|
|
189
195
|
}
|
|
196
|
+
|
|
197
|
+
if (record.toVisibility === 'visible') {
|
|
198
|
+
await tx.execute(sql`
|
|
199
|
+
insert into outbox (topic, payload)
|
|
200
|
+
values (
|
|
201
|
+
'post.edited',
|
|
202
|
+
${JSON.stringify({ postId: record.postId, threadId: record.threadId })}::jsonb
|
|
203
|
+
)
|
|
204
|
+
`)
|
|
205
|
+
}
|
|
190
206
|
})
|
|
191
207
|
}
|
|
192
208
|
|
|
@@ -221,6 +237,20 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
|
|
|
221
237
|
)
|
|
222
238
|
}
|
|
223
239
|
|
|
240
|
+
if (record.to === 'deleted' && record.from === 'visible') {
|
|
241
|
+
await tx.execute(sql`
|
|
242
|
+
insert into outbox (topic, payload)
|
|
243
|
+
values (
|
|
244
|
+
'post.deleted',
|
|
245
|
+
${JSON.stringify({
|
|
246
|
+
postId: record.postId,
|
|
247
|
+
threadId: record.threadId,
|
|
248
|
+
forumId: record.forumId,
|
|
249
|
+
})}::jsonb
|
|
250
|
+
)
|
|
251
|
+
`)
|
|
252
|
+
}
|
|
253
|
+
|
|
224
254
|
return true
|
|
225
255
|
})
|
|
226
256
|
}
|
package/src/presence-repo.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { ContentScope } from '@meith/core'
|
|
|
4
4
|
|
|
5
5
|
import type { Database } from './client'
|
|
6
6
|
import { resultRows } from './result-rows'
|
|
7
|
-
import { toDate } from './row-values'
|
|
7
|
+
import { toDate, toNullableDate } from './row-values'
|
|
8
8
|
import { inAudience } from './thread-audience'
|
|
9
9
|
import { visibleIn } from './visibility'
|
|
10
10
|
|
|
@@ -42,6 +42,12 @@ export interface OnlineRecord {
|
|
|
42
42
|
readonly at: Date | null
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export interface MemberPresence {
|
|
46
|
+
readonly userId: number
|
|
47
|
+
readonly lastActiveAt: Date | null
|
|
48
|
+
readonly invisible: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
export class PostgresPresenceRepository {
|
|
46
52
|
constructor(private readonly db: Database) {}
|
|
47
53
|
|
|
@@ -118,6 +124,28 @@ export class PostgresPresenceRepository {
|
|
|
118
124
|
}
|
|
119
125
|
}
|
|
120
126
|
|
|
127
|
+
async lastActiveFor(userIds: readonly number[]): Promise<readonly MemberPresence[]> {
|
|
128
|
+
if (userIds.length === 0) return []
|
|
129
|
+
|
|
130
|
+
const rows = resultRows(
|
|
131
|
+
await this.db.execute(sql`
|
|
132
|
+
select id, last_active_at, invisible
|
|
133
|
+
from users
|
|
134
|
+
where id in (${sql.join(
|
|
135
|
+
userIds.map((id) => sql`${id}`),
|
|
136
|
+
sql`, `,
|
|
137
|
+
)})
|
|
138
|
+
and state = 'active'
|
|
139
|
+
`),
|
|
140
|
+
) as Array<Record<string, unknown>>
|
|
141
|
+
|
|
142
|
+
return rows.map((row) => ({
|
|
143
|
+
userId: Number(row.id),
|
|
144
|
+
lastActiveAt: toNullableDate(row.last_active_at),
|
|
145
|
+
invisible: row.invisible === true,
|
|
146
|
+
}))
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
async touchGuest(input: {
|
|
122
150
|
readonly tokenHash: string
|
|
123
151
|
readonly location: {
|
package/src/read-state-repo.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { and, eq, gt, isNull, or, sql } from 'drizzle-orm'
|
|
2
2
|
|
|
3
3
|
import { PUBLIC_CONTENT } from '@meith/core'
|
|
4
|
-
import type { ReadState, ReadStateRepository } from '@meith/threads'
|
|
4
|
+
import type { ReadState, ReadStateRepository, ThreadReadMarker } from '@meith/threads'
|
|
5
5
|
|
|
6
6
|
import type { Database } from './client'
|
|
7
7
|
import { forumsRead, threads, threadsRead } from './schema'
|
|
@@ -55,6 +55,26 @@ export class PostgresReadStateRepository implements ReadStateRepository {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
async markerFor(userId: number, threadId: number, forumId: number): Promise<ThreadReadMarker> {
|
|
59
|
+
const [threadRows, forumRows] = await Promise.all([
|
|
60
|
+
this.db
|
|
61
|
+
.select({ lastReadPostId: threadsRead.lastReadPostId })
|
|
62
|
+
.from(threadsRead)
|
|
63
|
+
.where(and(eq(threadsRead.userId, userId), eq(threadsRead.threadId, threadId)))
|
|
64
|
+
.limit(1),
|
|
65
|
+
this.db
|
|
66
|
+
.select({ readAt: forumsRead.readAt })
|
|
67
|
+
.from(forumsRead)
|
|
68
|
+
.where(and(eq(forumsRead.userId, userId), eq(forumsRead.forumId, forumId)))
|
|
69
|
+
.limit(1),
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
lastReadPostId: threadRows[0]?.lastReadPostId ?? null,
|
|
74
|
+
forumReadAt: forumRows[0]?.readAt ?? null,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
async markForumsRead(userId: number, forumIds: readonly number[], at: Date): Promise<void> {
|
|
59
79
|
if (forumIds.length === 0) return
|
|
60
80
|
await this.db
|
package/src/report-repo.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { sql } from 'drizzle-orm'
|
|
|
3
3
|
import { PUBLIC_CONTENT } from '@meith/core'
|
|
4
4
|
import type {
|
|
5
5
|
NewReport,
|
|
6
|
+
ReportCategory,
|
|
6
7
|
ReportEvent,
|
|
7
8
|
ReportPage,
|
|
8
9
|
ReportRepository,
|
|
@@ -17,8 +18,9 @@ import { decodeCursor, encodeCursor } from './cursor'
|
|
|
17
18
|
import { resultRows } from './result-rows'
|
|
18
19
|
import { threads } from './schema'
|
|
19
20
|
import { idList } from './sql-lists'
|
|
20
|
-
import { logModeratorAction } from './thread-counters'
|
|
21
|
-
import { visibleIn } from './visibility'
|
|
21
|
+
import { type CounterTx, logModeratorAction } from './thread-counters'
|
|
22
|
+
import { PENDING_APPROVAL, VISIBLE, visibleIn } from './visibility'
|
|
23
|
+
import { applyVisibilityChangeCounters } from './visibility-counters'
|
|
22
24
|
|
|
23
25
|
interface RawReport {
|
|
24
26
|
id: number
|
|
@@ -29,6 +31,7 @@ interface RawReport {
|
|
|
29
31
|
target_label: string
|
|
30
32
|
reporter_user_id: number | null
|
|
31
33
|
reporter_username: string | null
|
|
34
|
+
category: ReportCategory
|
|
32
35
|
reason: string
|
|
33
36
|
status: ReportRow['status']
|
|
34
37
|
assigned_to_user_id: number | null
|
|
@@ -46,6 +49,7 @@ function toReport(row: RawReport): ReportRow {
|
|
|
46
49
|
targetLabel: row.target_label,
|
|
47
50
|
reporterUserId: row.reporter_user_id === null ? null : Number(row.reporter_user_id),
|
|
48
51
|
reporterUsername: row.reporter_username,
|
|
52
|
+
category: row.category,
|
|
49
53
|
reason: row.reason,
|
|
50
54
|
status: row.status,
|
|
51
55
|
assignedToUserId: row.assigned_to_user_id === null ? null : Number(row.assigned_to_user_id),
|
|
@@ -57,7 +61,7 @@ function toReport(row: RawReport): ReportRow {
|
|
|
57
61
|
const SELECT_REPORT = sql`
|
|
58
62
|
select r.id, r.target_kind, r.target_id, r.forum_id, r.thread_id, r.target_label,
|
|
59
63
|
r.reporter_user_id, reporter.username as reporter_username,
|
|
60
|
-
r.reason, r.status, r.assigned_to_user_id,
|
|
64
|
+
r.category, r.reason, r.status, r.assigned_to_user_id,
|
|
61
65
|
assignee.username as assigned_username, r.created_at
|
|
62
66
|
from reports r
|
|
63
67
|
left join users reporter on reporter.id = r.reporter_user_id
|
|
@@ -174,11 +178,11 @@ export class PostgresReportRepository implements ReportRepository {
|
|
|
174
178
|
await tx.execute(sql`
|
|
175
179
|
insert into reports
|
|
176
180
|
(target_kind, target_id, forum_id, thread_id, target_label,
|
|
177
|
-
reporter_user_id, reason, status, created_at, updated_at)
|
|
181
|
+
reporter_user_id, category, reason, status, created_at, updated_at)
|
|
178
182
|
values
|
|
179
183
|
(${report.target.kind}, ${report.target.id}, ${report.target.forumId},
|
|
180
184
|
${report.target.threadId}, ${report.target.label},
|
|
181
|
-
${report.reporterUserId}, ${report.reason}, 'open',
|
|
185
|
+
${report.reporterUserId}, ${report.category}, ${report.reason}, 'open',
|
|
182
186
|
${report.at}, ${report.at})
|
|
183
187
|
on conflict do nothing
|
|
184
188
|
returning id
|
|
@@ -192,21 +196,159 @@ export class PostgresReportRepository implements ReportRepository {
|
|
|
192
196
|
insert into report_events (report_id, actor_user_id, kind, created_at)
|
|
193
197
|
values (${row.id}, ${report.reporterUserId}, 'opened', ${report.at})
|
|
194
198
|
`)
|
|
199
|
+
|
|
200
|
+
await tx.execute(sql`
|
|
201
|
+
insert into outbox (topic, payload)
|
|
202
|
+
values (
|
|
203
|
+
'report.created',
|
|
204
|
+
${JSON.stringify({
|
|
205
|
+
reportId: Number(row.id),
|
|
206
|
+
targetKind: report.target.kind,
|
|
207
|
+
targetId: report.target.id,
|
|
208
|
+
reporterId: report.reporterUserId,
|
|
209
|
+
})}::jsonb
|
|
210
|
+
)
|
|
211
|
+
`)
|
|
212
|
+
|
|
213
|
+
await this.autoHold(tx, report)
|
|
214
|
+
|
|
195
215
|
return Number(row.id)
|
|
196
216
|
})
|
|
197
217
|
}
|
|
198
218
|
|
|
219
|
+
private async autoHold(tx: CounterTx, report: NewReport): Promise<void> {
|
|
220
|
+
if (report.flagThreshold <= 0 || report.target.kind !== 'post') return
|
|
221
|
+
|
|
222
|
+
const counted = resultRows(
|
|
223
|
+
await tx.execute(sql`
|
|
224
|
+
with last_hold as (
|
|
225
|
+
select max(created_at) as at
|
|
226
|
+
from admin_log
|
|
227
|
+
where action = 'post.autohold'
|
|
228
|
+
and (detail->>'postId')::int = ${report.target.id}
|
|
229
|
+
)
|
|
230
|
+
select count(distinct r.reporter_user_id)::int as reporters
|
|
231
|
+
from reports r, last_hold
|
|
232
|
+
where r.status = 'open'
|
|
233
|
+
and r.target_kind = 'post'
|
|
234
|
+
and r.target_id = ${report.target.id}
|
|
235
|
+
and r.reporter_user_id is not null
|
|
236
|
+
and (last_hold.at is null or r.created_at > last_hold.at)
|
|
237
|
+
`),
|
|
238
|
+
) as Array<{ reporters: number }>
|
|
239
|
+
if (Number(counted[0]?.reporters ?? 0) < report.flagThreshold) return
|
|
240
|
+
|
|
241
|
+
const found = resultRows(
|
|
242
|
+
await tx.execute(sql`
|
|
243
|
+
select thread_id, forum_id, is_first_post from posts where id = ${report.target.id}
|
|
244
|
+
`),
|
|
245
|
+
) as Array<{ thread_id: number; forum_id: number; is_first_post: boolean }>
|
|
246
|
+
const post = found[0]
|
|
247
|
+
if (!post) return
|
|
248
|
+
|
|
249
|
+
const threadId = Number(post.thread_id)
|
|
250
|
+
const forumId = Number(post.forum_id)
|
|
251
|
+
|
|
252
|
+
const held = post.is_first_post
|
|
253
|
+
? await this.holdThread(tx, threadId, forumId)
|
|
254
|
+
: await this.holdReply(tx, report.target.id, threadId, forumId)
|
|
255
|
+
if (held === null) return
|
|
256
|
+
|
|
257
|
+
await tx.execute(sql`
|
|
258
|
+
insert into admin_log (user_id, action, detail, created_at)
|
|
259
|
+
values (
|
|
260
|
+
null,
|
|
261
|
+
'post.autohold',
|
|
262
|
+
${JSON.stringify({
|
|
263
|
+
postId: report.target.id,
|
|
264
|
+
threadId,
|
|
265
|
+
forumId,
|
|
266
|
+
forumIds: [forumId],
|
|
267
|
+
heldPostIds: held,
|
|
268
|
+
})}::jsonb,
|
|
269
|
+
${report.at}
|
|
270
|
+
)
|
|
271
|
+
`)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async holdReply(
|
|
275
|
+
tx: CounterTx,
|
|
276
|
+
postId: number,
|
|
277
|
+
threadId: number,
|
|
278
|
+
forumId: number,
|
|
279
|
+
): Promise<readonly number[] | null> {
|
|
280
|
+
const moved = resultRows(
|
|
281
|
+
await tx.execute(sql`
|
|
282
|
+
update posts set visibility = ${PENDING_APPROVAL}
|
|
283
|
+
where id = ${postId} and visibility = ${VISIBLE}
|
|
284
|
+
returning author_user_id
|
|
285
|
+
`),
|
|
286
|
+
) as Array<{ author_user_id: number | null }>
|
|
287
|
+
const row = moved[0]
|
|
288
|
+
if (!row) return null
|
|
289
|
+
|
|
290
|
+
await applyVisibilityChangeCounters(tx, {
|
|
291
|
+
postId,
|
|
292
|
+
threadId,
|
|
293
|
+
forumId,
|
|
294
|
+
authorId: row.author_user_id === null ? null : Number(row.author_user_id),
|
|
295
|
+
isFirstPost: false,
|
|
296
|
+
delta: -1,
|
|
297
|
+
})
|
|
298
|
+
return [postId]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async holdThread(
|
|
302
|
+
tx: CounterTx,
|
|
303
|
+
threadId: number,
|
|
304
|
+
forumId: number,
|
|
305
|
+
): Promise<readonly number[] | null> {
|
|
306
|
+
const movedThread = resultRows(
|
|
307
|
+
await tx.execute(sql`
|
|
308
|
+
update threads set visibility = ${PENDING_APPROVAL}, updated_at = now()
|
|
309
|
+
where id = ${threadId} and visibility = ${VISIBLE}
|
|
310
|
+
returning id
|
|
311
|
+
`),
|
|
312
|
+
) as Array<{ id: number }>
|
|
313
|
+
if (movedThread.length === 0) return null
|
|
314
|
+
|
|
315
|
+
const posts = resultRows(
|
|
316
|
+
await tx.execute(sql`
|
|
317
|
+
update posts set visibility = ${PENDING_APPROVAL}
|
|
318
|
+
where thread_id = ${threadId} and visibility = ${VISIBLE}
|
|
319
|
+
returning id, author_user_id, is_first_post
|
|
320
|
+
`),
|
|
321
|
+
) as Array<{ id: number; author_user_id: number | null; is_first_post: boolean }>
|
|
322
|
+
|
|
323
|
+
for (const post of posts) {
|
|
324
|
+
await applyVisibilityChangeCounters(tx, {
|
|
325
|
+
postId: Number(post.id),
|
|
326
|
+
threadId,
|
|
327
|
+
forumId,
|
|
328
|
+
authorId: post.author_user_id === null ? null : Number(post.author_user_id),
|
|
329
|
+
isFirstPost: post.is_first_post === true,
|
|
330
|
+
delta: -1,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
return posts.map((post) => Number(post.id))
|
|
334
|
+
}
|
|
335
|
+
|
|
199
336
|
private scopePredicate(scope: ReportScope): ReturnType<typeof sql> {
|
|
200
337
|
const byForum = sql`r.forum_id in ${idList(scope.forumIds)}`
|
|
201
338
|
return scope.global ? sql`(${byForum} or r.forum_id is null)` : byForum
|
|
202
339
|
}
|
|
203
340
|
|
|
341
|
+
private categoryPredicate(category: ReportCategory | undefined): ReturnType<typeof sql> {
|
|
342
|
+
return category === undefined ? sql`` : sql`and r.category = ${category}`
|
|
343
|
+
}
|
|
344
|
+
|
|
204
345
|
async listOpen(
|
|
205
346
|
scope: ReportScope,
|
|
206
347
|
options: {
|
|
207
348
|
readonly limit: number
|
|
208
349
|
readonly after?: string
|
|
209
350
|
readonly offset?: number
|
|
351
|
+
readonly category?: ReportCategory
|
|
210
352
|
},
|
|
211
353
|
): Promise<ReportPage> {
|
|
212
354
|
const cursor = options.after === undefined ? null : decodeCursor(options.after)
|
|
@@ -215,7 +357,8 @@ export class PostgresReportRepository implements ReportRepository {
|
|
|
215
357
|
const rows = resultRows(
|
|
216
358
|
await this.db.execute(sql`
|
|
217
359
|
${SELECT_REPORT}
|
|
218
|
-
where r.status = 'open' and ${this.scopePredicate(scope)}
|
|
360
|
+
where r.status = 'open' and ${this.scopePredicate(scope)}
|
|
361
|
+
${this.categoryPredicate(options.category)} ${after}
|
|
219
362
|
order by r.created_at, r.id
|
|
220
363
|
limit ${options.limit + 1} offset ${options.offset ?? 0}
|
|
221
364
|
`),
|
|
@@ -228,11 +371,12 @@ export class PostgresReportRepository implements ReportRepository {
|
|
|
228
371
|
: { rows: page }
|
|
229
372
|
}
|
|
230
373
|
|
|
231
|
-
async countOpen(scope: ReportScope): Promise<number> {
|
|
374
|
+
async countOpen(scope: ReportScope, category?: ReportCategory): Promise<number> {
|
|
232
375
|
const rows = resultRows(
|
|
233
376
|
await this.db.execute(sql`
|
|
234
377
|
select count(*)::int as open from reports r
|
|
235
378
|
where r.status = 'open' and ${this.scopePredicate(scope)}
|
|
379
|
+
${this.categoryPredicate(category)}
|
|
236
380
|
`),
|
|
237
381
|
) as Array<{ open: number }>
|
|
238
382
|
return Number(rows[0]?.open ?? 0)
|
package/src/schema/content.ts
CHANGED
|
@@ -248,6 +248,9 @@ export type ReportTargetKind = (typeof REPORT_TARGET_KINDS)[number]
|
|
|
248
248
|
export const REPORT_STATUSES = ['open', 'resolved', 'rejected'] as const
|
|
249
249
|
export type ReportStatus = (typeof REPORT_STATUSES)[number]
|
|
250
250
|
|
|
251
|
+
export const REPORT_CATEGORIES = ['spam', 'off_topic', 'abuse', 'other'] as const
|
|
252
|
+
export type ReportCategory = (typeof REPORT_CATEGORIES)[number]
|
|
253
|
+
|
|
251
254
|
export const reports = pgTable(
|
|
252
255
|
'reports',
|
|
253
256
|
{
|
|
@@ -256,6 +259,8 @@ export const reports = pgTable(
|
|
|
256
259
|
targetKind: text('target_kind').notNull(),
|
|
257
260
|
targetId: integer('target_id').notNull(),
|
|
258
261
|
|
|
262
|
+
category: text('category').notNull().default('other'),
|
|
263
|
+
|
|
259
264
|
forumId: integer('forum_id').references(() => forums.id, {
|
|
260
265
|
onDelete: 'set null',
|
|
261
266
|
}),
|
package/src/schema/identity.ts
CHANGED
|
@@ -112,6 +112,9 @@ export const users = pgTable(
|
|
|
112
112
|
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
|
113
113
|
massMailOptInAt: timestamp('mass_mail_opt_in_at', { withTimezone: true }),
|
|
114
114
|
|
|
115
|
+
boardDigestCadence: text('board_digest_cadence').notNull().default('weekly'),
|
|
116
|
+
boardDigestSentAt: timestamp('board_digest_sent_at', { withTimezone: true }),
|
|
117
|
+
|
|
115
118
|
suspendedPostingUntil: timestamp('suspended_posting_until', { withTimezone: true }),
|
|
116
119
|
moderatedPostingUntil: timestamp('moderated_posting_until', { withTimezone: true }),
|
|
117
120
|
|
|
@@ -121,6 +124,9 @@ export const users = pgTable(
|
|
|
121
124
|
threadsPerPage: smallint('threads_per_page'),
|
|
122
125
|
invisible: boolean('invisible').notNull().default(false),
|
|
123
126
|
|
|
127
|
+
autoWatchOwnThreads: text('auto_watch_own_threads').notNull().default('none'),
|
|
128
|
+
autoWatchRepliedThreads: text('auto_watch_replied_threads').notNull().default('none'),
|
|
129
|
+
|
|
124
130
|
location: text('location'),
|
|
125
131
|
website: text('website'),
|
|
126
132
|
bio: text('bio'),
|
|
@@ -246,6 +252,27 @@ export const rememberTokens = pgTable(
|
|
|
246
252
|
],
|
|
247
253
|
)
|
|
248
254
|
|
|
255
|
+
export const feedTokens = pgTable(
|
|
256
|
+
'feed_tokens',
|
|
257
|
+
{
|
|
258
|
+
id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
|
|
259
|
+
|
|
260
|
+
userId: integer('user_id')
|
|
261
|
+
.notNull()
|
|
262
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
263
|
+
|
|
264
|
+
lookup: text('lookup').notNull(),
|
|
265
|
+
secretHash: text('secret_hash').notNull(),
|
|
266
|
+
|
|
267
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
268
|
+
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
|
269
|
+
},
|
|
270
|
+
(t) => [
|
|
271
|
+
uniqueIndex('feed_tokens_user_key').on(t.userId),
|
|
272
|
+
uniqueIndex('feed_tokens_lookup_key').on(t.lookup),
|
|
273
|
+
],
|
|
274
|
+
)
|
|
275
|
+
|
|
249
276
|
export const userIdentities = pgTable(
|
|
250
277
|
'user_identities',
|
|
251
278
|
{
|
|
@@ -570,7 +597,12 @@ export const notificationPreferences = pgTable(
|
|
|
570
597
|
push: boolean('push'),
|
|
571
598
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
572
599
|
},
|
|
573
|
-
(t) => [
|
|
600
|
+
(t) => [
|
|
601
|
+
primaryKey({ name: 'notification_preferences_pkey', columns: [t.userId, t.kind] }),
|
|
602
|
+
index('notification_preferences_kind_email_idx')
|
|
603
|
+
.on(t.kind, t.userId)
|
|
604
|
+
.where(sql`${t.email} = true`),
|
|
605
|
+
],
|
|
574
606
|
)
|
|
575
607
|
|
|
576
608
|
export const pushSubscriptions = pgTable(
|
package/src/schema/platform.ts
CHANGED
|
@@ -122,6 +122,8 @@ export const tasks = pgTable('tasks', {
|
|
|
122
122
|
|
|
123
123
|
intervalSeconds: integer('interval_seconds').notNull(),
|
|
124
124
|
|
|
125
|
+
schedule: text('schedule'),
|
|
126
|
+
|
|
125
127
|
enabled: boolean('enabled').notNull().default(true),
|
|
126
128
|
|
|
127
129
|
lastRunAt: timestamp('last_run_at', { withTimezone: true }),
|
package/src/search-repo.ts
CHANGED
|
@@ -10,13 +10,27 @@ import type {
|
|
|
10
10
|
SearchScope,
|
|
11
11
|
SearchSummary,
|
|
12
12
|
} from '@meith/search'
|
|
13
|
+
import { DEFAULT_SEARCH_LANGUAGE, SEARCH_LANGUAGES } from '@meith/settings'
|
|
13
14
|
|
|
14
15
|
import type { Database } from './client'
|
|
15
16
|
import { resultRows } from './result-rows'
|
|
16
17
|
import { inAudience } from './thread-audience'
|
|
17
18
|
import { visibleIn } from './visibility'
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
+
const SEARCH_LANGUAGE_SET: ReadonlySet<string> = new Set(SEARCH_LANGUAGES)
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_SEARCH_CONFIG: string = DEFAULT_SEARCH_LANGUAGE
|
|
23
|
+
|
|
24
|
+
export function resolveSearchConfig(value: string | null | undefined): string {
|
|
25
|
+
return typeof value === 'string' && SEARCH_LANGUAGE_SET.has(value) ? value : DEFAULT_SEARCH_CONFIG
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function readSearchConfig(db: Database): Promise<string> {
|
|
29
|
+
const rows = resultRows(
|
|
30
|
+
await db.execute(sql`select value from settings where key = 'search.language'`),
|
|
31
|
+
) as Array<{ value: string }>
|
|
32
|
+
return resolveSearchConfig(rows[0]?.value)
|
|
33
|
+
}
|
|
20
34
|
|
|
21
35
|
export const SEARCH_WINDOW = 20_000
|
|
22
36
|
|
|
@@ -51,10 +65,10 @@ export function renderExcerptHtml(headline: string): string {
|
|
|
51
65
|
.join('</b>')
|
|
52
66
|
}
|
|
53
67
|
|
|
54
|
-
export function searchVectorSql(subject: SQL | string, message: SQL | string): SQL {
|
|
68
|
+
export function searchVectorSql(config: string, subject: SQL | string, message: SQL | string): SQL {
|
|
55
69
|
return sql`
|
|
56
|
-
setweight(to_tsvector(${
|
|
57
|
-
setweight(to_tsvector(${
|
|
70
|
+
setweight(to_tsvector(${config}, coalesce(${subject}, '')), 'A') ||
|
|
71
|
+
setweight(to_tsvector(${config}, coalesce(${message}, '')), 'B')
|
|
58
72
|
`
|
|
59
73
|
}
|
|
60
74
|
|
|
@@ -77,13 +91,20 @@ export interface ReindexResult {
|
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
export class PostgresSearchRepository {
|
|
80
|
-
|
|
94
|
+
private readonly config: string
|
|
95
|
+
|
|
96
|
+
constructor(
|
|
97
|
+
private readonly db: Database,
|
|
98
|
+
config: string = DEFAULT_SEARCH_CONFIG,
|
|
99
|
+
) {
|
|
100
|
+
this.config = resolveSearchConfig(config)
|
|
101
|
+
}
|
|
81
102
|
|
|
82
103
|
async search(query: SearchQuery, scope: SearchScope): Promise<SearchResults> {
|
|
83
|
-
const conditions = matchConditions(query, scope)
|
|
104
|
+
const conditions = matchConditions(this.config, query, scope)
|
|
84
105
|
if (conditions === null) return { hits: [], nextCursor: null }
|
|
85
106
|
|
|
86
|
-
const rank = rankSql(query.terms)
|
|
107
|
+
const rank = rankSql(this.config, query.terms)
|
|
87
108
|
const finalOrder = orderSql(query.sort)
|
|
88
109
|
|
|
89
110
|
const selection = sql`
|
|
@@ -104,8 +125,8 @@ export class PostgresSearchRepository {
|
|
|
104
125
|
await this.db.execute(sql`
|
|
105
126
|
select post_id, thread_id, forum_id, thread_title, thread_slug,
|
|
106
127
|
author_user_id, author_username, created_at, rank,
|
|
107
|
-
ts_headline(${
|
|
108
|
-
websearch_to_tsquery(${
|
|
128
|
+
ts_headline(${this.config}, excerpt_source,
|
|
129
|
+
websearch_to_tsquery(${this.config}, ${query.terms}),
|
|
109
130
|
${HEADLINE_OPTIONS}) as excerpt
|
|
110
131
|
from ${candidates}
|
|
111
132
|
order by ${finalOrder}
|
|
@@ -136,7 +157,7 @@ export class PostgresSearchRepository {
|
|
|
136
157
|
}
|
|
137
158
|
|
|
138
159
|
async summarize(query: SearchQuery, scope: SearchScope): Promise<SearchSummary> {
|
|
139
|
-
const conditions = matchConditions(query, scope)
|
|
160
|
+
const conditions = matchConditions(this.config, query, scope)
|
|
140
161
|
if (conditions === null) return NOTHING
|
|
141
162
|
|
|
142
163
|
const rows = resultRows(
|
|
@@ -204,10 +225,11 @@ export class PostgresSearchRepository {
|
|
|
204
225
|
}
|
|
205
226
|
|
|
206
227
|
async reindexChunk(afterPostId: number, limit: number): Promise<ReindexResult> {
|
|
228
|
+
const config = await readSearchConfig(this.db)
|
|
207
229
|
const rows = resultRows(
|
|
208
230
|
await this.db.execute(sql`
|
|
209
231
|
update posts p
|
|
210
|
-
set search_vector = ${searchVectorSql(indexedSubjectSql(sql`p`), sql`p.message`)},
|
|
232
|
+
set search_vector = ${searchVectorSql(config, indexedSubjectSql(sql`p`), sql`p.message`)},
|
|
211
233
|
search_version = ${SEARCH_DOCUMENT_VERSION}
|
|
212
234
|
where p.id in (
|
|
213
235
|
select id from posts
|
|
@@ -245,10 +267,19 @@ export class PostgresSearchRepository {
|
|
|
245
267
|
async invalidateIndex(): Promise<void> {
|
|
246
268
|
await this.db.execute(sql`update posts set search_vector = null, search_version = 0`)
|
|
247
269
|
}
|
|
270
|
+
|
|
271
|
+
async markForReindex(): Promise<number> {
|
|
272
|
+
const rows = resultRows(
|
|
273
|
+
await this.db.execute(
|
|
274
|
+
sql`update posts set search_version = 0 where search_version <> 0 returning id`,
|
|
275
|
+
),
|
|
276
|
+
) as Array<{ id: number }>
|
|
277
|
+
return rows.length
|
|
278
|
+
}
|
|
248
279
|
}
|
|
249
280
|
|
|
250
|
-
function rankSql(terms: string): SQL {
|
|
251
|
-
return sql`ts_rank_cd(p.search_vector, websearch_to_tsquery(${
|
|
281
|
+
function rankSql(config: string, terms: string): SQL {
|
|
282
|
+
return sql`ts_rank_cd(p.search_vector, websearch_to_tsquery(${config}, ${terms}))`
|
|
252
283
|
}
|
|
253
284
|
|
|
254
285
|
function excerptSourceSql(match: SearchQuery['match']): SQL {
|
|
@@ -263,7 +294,7 @@ function orderSql(sort: SearchQuery['sort']): SQL {
|
|
|
263
294
|
: sql`post_id asc`
|
|
264
295
|
}
|
|
265
296
|
|
|
266
|
-
function matchConditions(query: SearchQuery, scope: SearchScope): SQL[] | null {
|
|
297
|
+
function matchConditions(config: string, query: SearchQuery, scope: SearchScope): SQL[] | null {
|
|
267
298
|
if (query.terms.trim() === '') return null
|
|
268
299
|
|
|
269
300
|
const allowed =
|
|
@@ -279,7 +310,7 @@ function matchConditions(query: SearchQuery, scope: SearchScope): SQL[] | null {
|
|
|
279
310
|
...scope,
|
|
280
311
|
forumIds: allowed,
|
|
281
312
|
}),
|
|
282
|
-
sql`p.search_vector @@ websearch_to_tsquery(${
|
|
313
|
+
sql`p.search_vector @@ websearch_to_tsquery(${config}, ${query.terms})`,
|
|
283
314
|
]
|
|
284
315
|
|
|
285
316
|
conditions.push(visibleIn(sql`p.visibility`, scope.content))
|
|
@@ -287,8 +318,8 @@ function matchConditions(query: SearchQuery, scope: SearchScope): SQL[] | null {
|
|
|
287
318
|
|
|
288
319
|
if (query.match === 'titles') {
|
|
289
320
|
conditions.push(
|
|
290
|
-
sql`to_tsvector(${
|
|
291
|
-
@@ websearch_to_tsquery(${
|
|
321
|
+
sql`to_tsvector(${config}, coalesce(${indexedSubjectSql(sql`p`)}, ''))
|
|
322
|
+
@@ websearch_to_tsquery(${config}, ${query.terms})`,
|
|
292
323
|
)
|
|
293
324
|
}
|
|
294
325
|
|