@meith/db 0.30.1 → 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
|
@@ -421,6 +421,48 @@
|
|
|
421
421
|
"when": 1788876000000,
|
|
422
422
|
"tag": "0059_member_directory_navigation",
|
|
423
423
|
"breakpoints": true
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
"idx": 60,
|
|
427
|
+
"version": "7",
|
|
428
|
+
"when": 1789000000000,
|
|
429
|
+
"tag": "0060_webhook_payload_format",
|
|
430
|
+
"breakpoints": true
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
"idx": 61,
|
|
434
|
+
"version": "7",
|
|
435
|
+
"when": 1789100000000,
|
|
436
|
+
"tag": "0061_task_schedule",
|
|
437
|
+
"breakpoints": true
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
"idx": 62,
|
|
441
|
+
"version": "7",
|
|
442
|
+
"when": 1788314541634,
|
|
443
|
+
"tag": "0062_pretty_zombie",
|
|
444
|
+
"breakpoints": true
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
"idx": 63,
|
|
448
|
+
"version": "7",
|
|
449
|
+
"when": 1788327929586,
|
|
450
|
+
"tag": "0063_board_digest",
|
|
451
|
+
"breakpoints": true
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
"idx": 64,
|
|
455
|
+
"version": "7",
|
|
456
|
+
"when": 1788330549648,
|
|
457
|
+
"tag": "0064_redundant_lady_bullseye",
|
|
458
|
+
"breakpoints": true
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
"idx": 65,
|
|
462
|
+
"version": "7",
|
|
463
|
+
"when": 1788346593870,
|
|
464
|
+
"tag": "0065_serious_puff_adder",
|
|
465
|
+
"breakpoints": true
|
|
424
466
|
}
|
|
425
467
|
]
|
|
426
468
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,35 +22,36 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"drizzle-orm": "^0.45.2",
|
|
24
24
|
"postgres": "^3.4.7",
|
|
25
|
-
"@meith/accounts": "0.
|
|
26
|
-
"@meith/admin": "0.
|
|
27
|
-
"@meith/antispam": "0.
|
|
28
|
-
"@meith/api": "0.
|
|
29
|
-
"@meith/
|
|
30
|
-
"@meith/
|
|
31
|
-
"@meith/avatars": "0.
|
|
32
|
-
"@meith/
|
|
33
|
-
"@meith/
|
|
34
|
-
"@meith/
|
|
35
|
-
"@meith/
|
|
36
|
-
"@meith/
|
|
37
|
-
"@meith/
|
|
38
|
-
"@meith/
|
|
39
|
-
"@meith/
|
|
40
|
-
"@meith/
|
|
41
|
-
"@meith/
|
|
42
|
-
"@meith/
|
|
43
|
-
"@meith/
|
|
44
|
-
"@meith/
|
|
45
|
-
"@meith/
|
|
46
|
-
"@meith/
|
|
47
|
-
"@meith/
|
|
48
|
-
"@meith/
|
|
49
|
-
"@meith/search": "0.
|
|
50
|
-
"@meith/
|
|
51
|
-
"@meith/
|
|
52
|
-
"@meith/
|
|
53
|
-
"@meith/
|
|
25
|
+
"@meith/accounts": "0.32.0",
|
|
26
|
+
"@meith/admin": "0.32.0",
|
|
27
|
+
"@meith/antispam": "0.32.0",
|
|
28
|
+
"@meith/api": "0.32.0",
|
|
29
|
+
"@meith/attachments": "0.32.0",
|
|
30
|
+
"@meith/authorization": "0.32.0",
|
|
31
|
+
"@meith/avatars": "0.32.0",
|
|
32
|
+
"@meith/board-digest": "0.32.0",
|
|
33
|
+
"@meith/core": "0.32.0",
|
|
34
|
+
"@meith/drafts": "0.32.0",
|
|
35
|
+
"@meith/events": "0.32.0",
|
|
36
|
+
"@meith/forums": "0.32.0",
|
|
37
|
+
"@meith/groups": "0.32.0",
|
|
38
|
+
"@meith/i18n": "0.32.0",
|
|
39
|
+
"@meith/markdown": "0.32.0",
|
|
40
|
+
"@meith/marketplace": "0.32.0",
|
|
41
|
+
"@meith/messages": "0.32.0",
|
|
42
|
+
"@meith/moderation": "0.32.0",
|
|
43
|
+
"@meith/notifications": "0.32.0",
|
|
44
|
+
"@meith/plugin-kit": "0.32.0",
|
|
45
|
+
"@meith/polls": "0.32.0",
|
|
46
|
+
"@meith/profile-fields": "0.32.0",
|
|
47
|
+
"@meith/relations": "0.32.0",
|
|
48
|
+
"@meith/reputation": "0.32.0",
|
|
49
|
+
"@meith/search": "0.32.0",
|
|
50
|
+
"@meith/settings": "0.32.0",
|
|
51
|
+
"@meith/signatures": "0.32.0",
|
|
52
|
+
"@meith/subscriptions": "0.32.0",
|
|
53
|
+
"@meith/tasks": "0.32.0",
|
|
54
|
+
"@meith/threads": "0.32.0"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
57
|
"drizzle-kit": "^0.31.6",
|
package/src/account-repos.ts
CHANGED
|
@@ -98,21 +98,38 @@ export class PostgresAccountRepository implements AccountRepository {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
async create(input: NewAccount): Promise<AccountRecord> {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
101
|
+
return this.db.transaction(async (tx) => {
|
|
102
|
+
const rows = await tx
|
|
103
|
+
.insert(users)
|
|
104
|
+
.values({
|
|
105
|
+
username: input.username,
|
|
106
|
+
usernameLower: input.usernameLower,
|
|
107
|
+
email: input.email,
|
|
108
|
+
emailLower: input.emailLower,
|
|
109
|
+
passwordHash: input.passwordHash,
|
|
110
|
+
passwordAlgo: input.passwordAlgo,
|
|
111
|
+
state: input.state,
|
|
112
|
+
primaryGroupId: input.primaryGroupId,
|
|
113
|
+
registrationIpPrefix: input.registrationIpPrefix ?? null,
|
|
114
|
+
})
|
|
115
|
+
.returning(ACCOUNT_COLUMNS)
|
|
116
|
+
|
|
117
|
+
const record = toAccountRecord(rows[0]!)
|
|
118
|
+
|
|
119
|
+
await tx.execute(sql`
|
|
120
|
+
insert into outbox (topic, payload)
|
|
121
|
+
values (
|
|
122
|
+
'user.registered',
|
|
123
|
+
${JSON.stringify({
|
|
124
|
+
userId: record.id,
|
|
125
|
+
email: input.email,
|
|
126
|
+
requiresActivation: input.state === 'awaiting_activation',
|
|
127
|
+
})}::jsonb
|
|
128
|
+
)
|
|
129
|
+
`)
|
|
130
|
+
|
|
131
|
+
return record
|
|
132
|
+
})
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
async recordLastIpPrefix(userId: number, prefix: string): Promise<void> {
|
package/src/api-repo.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm'
|
|
2
2
|
|
|
3
|
-
import type { ApiTokenRecord, RateLimitStore, Scope } from '@meith/api'
|
|
3
|
+
import type { ApiTokenRecord, RateLimitStore, Scope, WebhookFormat, WebhookTopic } from '@meith/api'
|
|
4
4
|
|
|
5
5
|
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
6
7
|
|
|
7
8
|
interface TokenRow {
|
|
8
9
|
id: number
|
|
@@ -182,28 +183,204 @@ export interface WebhookDeliveryRow {
|
|
|
182
183
|
readonly secret: string
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
export interface WebhookActiveSubscription {
|
|
187
|
+
readonly id: number
|
|
188
|
+
readonly format: WebhookFormat
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface WebhookSummary {
|
|
192
|
+
readonly id: number
|
|
193
|
+
readonly url: string
|
|
194
|
+
readonly topics: readonly WebhookTopic[]
|
|
195
|
+
readonly active: boolean
|
|
196
|
+
readonly format: WebhookFormat
|
|
197
|
+
readonly createdAt: Date
|
|
198
|
+
readonly delivered: number
|
|
199
|
+
readonly pending: number
|
|
200
|
+
readonly dead: number
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface WebhookDeliveryLogRow {
|
|
204
|
+
readonly id: number
|
|
205
|
+
readonly deliveryId: string
|
|
206
|
+
readonly topic: string
|
|
207
|
+
readonly status: string
|
|
208
|
+
readonly attempts: number
|
|
209
|
+
readonly lastStatusCode: number | null
|
|
210
|
+
readonly lastError: string | null
|
|
211
|
+
readonly createdAt: Date
|
|
212
|
+
readonly completedAt: Date | null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseTopics(raw: unknown): readonly WebhookTopic[] {
|
|
216
|
+
return Array.isArray(raw)
|
|
217
|
+
? (raw.filter((value) => typeof value === 'string') as WebhookTopic[])
|
|
218
|
+
: []
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function parseFormat(raw: unknown): WebhookFormat {
|
|
222
|
+
return raw === 'discord' ? 'discord' : 'json'
|
|
223
|
+
}
|
|
224
|
+
|
|
185
225
|
export class PostgresWebhookRepository {
|
|
186
226
|
constructor(private readonly db: Database) {}
|
|
187
227
|
|
|
188
228
|
async enqueue(
|
|
229
|
+
webhookId: number,
|
|
189
230
|
topic: string,
|
|
190
231
|
deliveryId: string,
|
|
191
232
|
payload: Record<string, unknown>,
|
|
192
|
-
): Promise<
|
|
193
|
-
const rows = (
|
|
233
|
+
): Promise<boolean> {
|
|
234
|
+
const rows = resultRows(
|
|
235
|
+
await this.db.execute(sql`
|
|
194
236
|
insert into webhook_deliveries (webhook_id, delivery_id, topic, payload)
|
|
195
|
-
|
|
196
|
-
from webhooks w
|
|
197
|
-
where w.active = true and w.topics ? ${topic}
|
|
237
|
+
values (${webhookId}, ${deliveryId}, ${topic}, ${JSON.stringify(payload)}::jsonb)
|
|
198
238
|
on conflict (webhook_id, delivery_id) do nothing
|
|
199
239
|
returning id
|
|
200
|
-
`)
|
|
240
|
+
`),
|
|
241
|
+
)
|
|
201
242
|
|
|
202
|
-
return rows.length
|
|
243
|
+
return rows.length > 0
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async listActiveByTopic(topic: string): Promise<readonly WebhookActiveSubscription[]> {
|
|
247
|
+
const rows = resultRows<{ id: number; format: unknown }>(
|
|
248
|
+
await this.db.execute(sql`
|
|
249
|
+
select id, format
|
|
250
|
+
from webhooks
|
|
251
|
+
where active = true and topics ? ${topic}
|
|
252
|
+
order by id
|
|
253
|
+
`),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return rows.map((row) => ({ id: Number(row.id), format: parseFormat(row.format) }))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async listAll(limit = 200): Promise<readonly WebhookSummary[]> {
|
|
260
|
+
const rows = resultRows<{
|
|
261
|
+
id: number
|
|
262
|
+
url: string
|
|
263
|
+
topics: unknown
|
|
264
|
+
active: boolean
|
|
265
|
+
format: unknown
|
|
266
|
+
created_at: Date | string
|
|
267
|
+
delivered: number | string
|
|
268
|
+
pending: number | string
|
|
269
|
+
dead: number | string
|
|
270
|
+
}>(
|
|
271
|
+
await this.db.execute(sql`
|
|
272
|
+
select w.id, w.url, w.topics, w.active, w.format, w.created_at,
|
|
273
|
+
count(d.id) filter (where d.status = 'delivered') as delivered,
|
|
274
|
+
count(d.id) filter (where d.status = 'pending') as pending,
|
|
275
|
+
count(d.id) filter (where d.status = 'dead') as dead
|
|
276
|
+
from webhooks w
|
|
277
|
+
left join webhook_deliveries d on d.webhook_id = w.id
|
|
278
|
+
group by w.id
|
|
279
|
+
order by w.created_at desc, w.id desc
|
|
280
|
+
limit ${limit}
|
|
281
|
+
`),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return rows.map((row) => ({
|
|
285
|
+
id: Number(row.id),
|
|
286
|
+
url: row.url,
|
|
287
|
+
topics: parseTopics(row.topics),
|
|
288
|
+
active: row.active,
|
|
289
|
+
format: parseFormat(row.format),
|
|
290
|
+
createdAt: new Date(row.created_at),
|
|
291
|
+
delivered: Number(row.delivered),
|
|
292
|
+
pending: Number(row.pending),
|
|
293
|
+
dead: Number(row.dead),
|
|
294
|
+
}))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async recentDeliveries(webhookId: number, limit = 20): Promise<readonly WebhookDeliveryLogRow[]> {
|
|
298
|
+
const rows = resultRows<{
|
|
299
|
+
id: number
|
|
300
|
+
delivery_id: string
|
|
301
|
+
topic: string
|
|
302
|
+
status: string
|
|
303
|
+
attempts: number
|
|
304
|
+
last_status_code: number | null
|
|
305
|
+
last_error: string | null
|
|
306
|
+
created_at: Date | string
|
|
307
|
+
completed_at: Date | string | null
|
|
308
|
+
}>(
|
|
309
|
+
await this.db.execute(sql`
|
|
310
|
+
select id, delivery_id, topic, status, attempts, last_status_code, last_error,
|
|
311
|
+
created_at, completed_at
|
|
312
|
+
from webhook_deliveries
|
|
313
|
+
where webhook_id = ${webhookId}
|
|
314
|
+
order by created_at desc, id desc
|
|
315
|
+
limit ${limit}
|
|
316
|
+
`),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return rows.map((row) => ({
|
|
320
|
+
id: Number(row.id),
|
|
321
|
+
deliveryId: row.delivery_id,
|
|
322
|
+
topic: row.topic,
|
|
323
|
+
status: row.status,
|
|
324
|
+
attempts: Number(row.attempts),
|
|
325
|
+
lastStatusCode: row.last_status_code === null ? null : Number(row.last_status_code),
|
|
326
|
+
lastError: row.last_error,
|
|
327
|
+
createdAt: new Date(row.created_at),
|
|
328
|
+
completedAt: row.completed_at === null ? null : new Date(row.completed_at),
|
|
329
|
+
}))
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async create(input: {
|
|
333
|
+
readonly url: string
|
|
334
|
+
readonly secret: string
|
|
335
|
+
readonly topics: readonly WebhookTopic[]
|
|
336
|
+
readonly active: boolean
|
|
337
|
+
readonly format: WebhookFormat
|
|
338
|
+
readonly createdBy: number | null
|
|
339
|
+
}): Promise<number> {
|
|
340
|
+
const rows = resultRows<{ id: number }>(
|
|
341
|
+
await this.db.execute(sql`
|
|
342
|
+
insert into webhooks (url, secret, topics, active, format, created_by)
|
|
343
|
+
values (${input.url}, ${input.secret}, ${JSON.stringify(input.topics)}::jsonb,
|
|
344
|
+
${input.active}, ${input.format}, ${input.createdBy})
|
|
345
|
+
returning id
|
|
346
|
+
`),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return rows[0]!.id
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async setActive(id: number, active: boolean): Promise<boolean> {
|
|
353
|
+
const rows = resultRows(
|
|
354
|
+
await this.db.execute(sql`
|
|
355
|
+
update webhooks set active = ${active}
|
|
356
|
+
where id = ${id}
|
|
357
|
+
returning id
|
|
358
|
+
`),
|
|
359
|
+
)
|
|
360
|
+
return rows.length > 0
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async remove(id: number): Promise<boolean> {
|
|
364
|
+
const rows = resultRows(
|
|
365
|
+
await this.db.execute(sql`
|
|
366
|
+
delete from webhooks where id = ${id} returning id
|
|
367
|
+
`),
|
|
368
|
+
)
|
|
369
|
+
return rows.length > 0
|
|
203
370
|
}
|
|
204
371
|
|
|
205
372
|
async claimDue(now: Date, limit: number): Promise<readonly WebhookDeliveryRow[]> {
|
|
206
|
-
const rows =
|
|
373
|
+
const rows = resultRows<{
|
|
374
|
+
id: number
|
|
375
|
+
webhook_id: number
|
|
376
|
+
delivery_id: string
|
|
377
|
+
topic: string
|
|
378
|
+
payload: Record<string, unknown>
|
|
379
|
+
attempts: number
|
|
380
|
+
url: string
|
|
381
|
+
secret: string
|
|
382
|
+
}>(
|
|
383
|
+
await this.db.execute(sql`
|
|
207
384
|
with due as (
|
|
208
385
|
select d.id
|
|
209
386
|
from webhook_deliveries d
|
|
@@ -217,16 +394,8 @@ export class PostgresWebhookRepository {
|
|
|
217
394
|
from due, webhooks w
|
|
218
395
|
where d.id = due.id and w.id = d.webhook_id
|
|
219
396
|
returning d.id, d.webhook_id, d.delivery_id, d.topic, d.payload, d.attempts, w.url, w.secret
|
|
220
|
-
`)
|
|
221
|
-
|
|
222
|
-
webhook_id: number
|
|
223
|
-
delivery_id: string
|
|
224
|
-
topic: string
|
|
225
|
-
payload: Record<string, unknown>
|
|
226
|
-
attempts: number
|
|
227
|
-
url: string
|
|
228
|
-
secret: string
|
|
229
|
-
}[]
|
|
397
|
+
`),
|
|
398
|
+
)
|
|
230
399
|
|
|
231
400
|
return rows.map((row) => ({
|
|
232
401
|
id: row.id,
|
|
@@ -265,12 +434,14 @@ export class PostgresWebhookRepository {
|
|
|
265
434
|
}
|
|
266
435
|
|
|
267
436
|
async retryDead(id: number, at: Date): Promise<boolean> {
|
|
268
|
-
const rows = (
|
|
437
|
+
const rows = resultRows(
|
|
438
|
+
await this.db.execute(sql`
|
|
269
439
|
update webhook_deliveries
|
|
270
440
|
set status = 'pending', attempts = 0, next_attempt_at = ${at}, last_error = null
|
|
271
441
|
where id = ${id} and status = 'dead'
|
|
272
442
|
returning id
|
|
273
|
-
`)
|
|
443
|
+
`),
|
|
444
|
+
)
|
|
274
445
|
return rows.length > 0
|
|
275
446
|
}
|
|
276
447
|
}
|
package/src/attachment-repo.ts
CHANGED
|
@@ -190,6 +190,32 @@ export class PostgresAttachmentRepository implements AttachmentRepository {
|
|
|
190
190
|
return rows[0] === undefined ? null : toRecord(rows[0])
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
async deleteForPost(id: number, postId: number): Promise<AttachmentRecord | null> {
|
|
194
|
+
return this.db.transaction(async (tx) => {
|
|
195
|
+
const rows = resultRows(
|
|
196
|
+
await tx.execute(sql`
|
|
197
|
+
delete from attachments
|
|
198
|
+
where id = ${id} and post_id = ${postId}
|
|
199
|
+
returning ${COLUMNS}
|
|
200
|
+
`),
|
|
201
|
+
) as RawAttachment[]
|
|
202
|
+
|
|
203
|
+
const row = rows[0]
|
|
204
|
+
if (row === undefined) return null
|
|
205
|
+
|
|
206
|
+
const record = toRecord(row)
|
|
207
|
+
for (const key of [record.storageKey, record.sourceKey, record.thumbnailKey]) {
|
|
208
|
+
if (key === null) continue
|
|
209
|
+
await tx.execute(sql`
|
|
210
|
+
insert into attachment_orphans (storage_key) values (${key})
|
|
211
|
+
on conflict (storage_key) do nothing
|
|
212
|
+
`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return record
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
193
219
|
async deleteOrphans(before: Date, limit: number): Promise<readonly AttachmentRecord[]> {
|
|
194
220
|
const rows = resultRows(
|
|
195
221
|
await this.db.execute(sql`
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { BoardDigestCadence, BoardDigestRepository, EligibleMember } from '@meith/board-digest'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { toDate } from './row-values'
|
|
8
|
+
|
|
9
|
+
export class PostgresBoardDigestRepository implements BoardDigestRepository {
|
|
10
|
+
constructor(private readonly db: Database) {}
|
|
11
|
+
|
|
12
|
+
async dueMembers(input: {
|
|
13
|
+
readonly cadence: BoardDigestCadence
|
|
14
|
+
readonly dueBefore: Date
|
|
15
|
+
readonly lapsedBefore: Date
|
|
16
|
+
readonly limit: number
|
|
17
|
+
}): Promise<readonly EligibleMember[]> {
|
|
18
|
+
const rows = resultRows(
|
|
19
|
+
await this.db.execute(sql`
|
|
20
|
+
select u.id as user_id, u.last_active_at
|
|
21
|
+
from users u
|
|
22
|
+
join notification_preferences p
|
|
23
|
+
on p.user_id = u.id and p.kind = 'board.digest' and p.email = true
|
|
24
|
+
where u.state = 'active'
|
|
25
|
+
and u.board_digest_cadence = ${input.cadence}
|
|
26
|
+
and u.last_active_at is not null
|
|
27
|
+
and u.last_active_at < ${input.lapsedBefore}
|
|
28
|
+
and (u.board_digest_sent_at is null or u.board_digest_sent_at < ${input.dueBefore})
|
|
29
|
+
order by u.id
|
|
30
|
+
limit ${input.limit}
|
|
31
|
+
`),
|
|
32
|
+
) as Array<{ user_id: number; last_active_at: string | Date }>
|
|
33
|
+
|
|
34
|
+
return rows.map((row) => ({
|
|
35
|
+
userId: Number(row.user_id),
|
|
36
|
+
lastActiveAt: toDate(row.last_active_at),
|
|
37
|
+
}))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async recordDigestRun(input: { readonly userId: number; readonly at: Date }): Promise<void> {
|
|
41
|
+
await this.db.execute(sql`
|
|
42
|
+
update users set board_digest_sent_at = ${input.at} where id = ${input.userId}
|
|
43
|
+
`)
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/content-counters.ts
CHANGED
|
@@ -77,6 +77,20 @@ export async function applyCreatedContentCounters(
|
|
|
77
77
|
})}::jsonb
|
|
78
78
|
)
|
|
79
79
|
`)
|
|
80
|
+
|
|
81
|
+
if (content.isNewThread) {
|
|
82
|
+
await tx.execute(sql`
|
|
83
|
+
insert into outbox (topic, payload)
|
|
84
|
+
values (
|
|
85
|
+
'thread.created',
|
|
86
|
+
${JSON.stringify({
|
|
87
|
+
threadId: content.threadId,
|
|
88
|
+
forumId: content.forumId,
|
|
89
|
+
authorId: content.authorId,
|
|
90
|
+
})}::jsonb
|
|
91
|
+
)
|
|
92
|
+
`)
|
|
93
|
+
}
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
export async function rollUpAncestorCounters(db: Database, postId: number): Promise<boolean> {
|
package/src/discovery-repo.ts
CHANGED
|
@@ -79,10 +79,31 @@ export class PostgresDiscoveryRepository {
|
|
|
79
79
|
)
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
async unread(
|
|
83
|
+
userId: number,
|
|
84
|
+
query: DiscoveryQuery,
|
|
85
|
+
scope: DiscoveryScope,
|
|
86
|
+
): Promise<DiscoveryPage> {
|
|
87
|
+
return this.page(
|
|
88
|
+
[
|
|
89
|
+
sql`t.last_post_id is not null`,
|
|
90
|
+
sql`(tr.last_read_post_id is null or t.last_post_id > tr.last_read_post_id)`,
|
|
91
|
+
sql`(fr.read_at is null or t.last_post_at > fr.read_at)`,
|
|
92
|
+
],
|
|
93
|
+
query,
|
|
94
|
+
scope,
|
|
95
|
+
[
|
|
96
|
+
sql`left join threads_read tr on tr.thread_id = t.id and tr.user_id = ${userId}`,
|
|
97
|
+
sql`left join forums_read fr on fr.forum_id = t.forum_id and fr.user_id = ${userId}`,
|
|
98
|
+
],
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
private async page(
|
|
83
103
|
conditions: readonly SQL[],
|
|
84
104
|
query: DiscoveryQuery,
|
|
85
105
|
scope: DiscoveryScope,
|
|
106
|
+
joins: readonly SQL[] = [],
|
|
86
107
|
): Promise<DiscoveryPage> {
|
|
87
108
|
if (audienceIsEmpty(scope)) return { rows: [], nextCursor: null }
|
|
88
109
|
|
|
@@ -107,6 +128,7 @@ export class PostgresDiscoveryRepository {
|
|
|
107
128
|
t.reply_count, t.last_post_at, t.last_post_username
|
|
108
129
|
from threads t
|
|
109
130
|
join forums f on f.id = t.forum_id
|
|
131
|
+
${sql.join([...joins], sql` `)}
|
|
110
132
|
where ${sql.join(where, sql` and `)}
|
|
111
133
|
order by t.last_post_at desc, t.id desc
|
|
112
134
|
limit ${query.limit}
|
package/src/draft-repo.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm'
|
|
2
2
|
|
|
3
|
-
import type { Draft, DraftRepository } from '@meith/drafts'
|
|
3
|
+
import type { Draft, DraftRepository, DraftSummary } from '@meith/drafts'
|
|
4
4
|
import { BodyFormat, sourceAsMarkdown } from '@meith/markdown'
|
|
5
5
|
|
|
6
6
|
import type { Database } from './client'
|
|
@@ -56,4 +56,43 @@ export class PostgresDraftRepository implements DraftRepository {
|
|
|
56
56
|
and thread_id is not distinct from ${threadId}
|
|
57
57
|
`)
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
async listByUser(userId: number): Promise<readonly DraftSummary[]> {
|
|
61
|
+
const rows = resultRows(
|
|
62
|
+
await this.db.execute(sql`
|
|
63
|
+
select d.forum_id, f.title as forum_title, f.slug as forum_slug,
|
|
64
|
+
d.thread_id, t.title as thread_title, t.slug as thread_slug,
|
|
65
|
+
d.title, d.message, d.body_format, d.updated_at
|
|
66
|
+
from post_drafts d
|
|
67
|
+
join forums f on f.id = d.forum_id
|
|
68
|
+
left join threads t on t.id = d.thread_id
|
|
69
|
+
where d.user_id = ${userId}
|
|
70
|
+
and (d.thread_id is null or t.id is not null)
|
|
71
|
+
order by d.updated_at desc
|
|
72
|
+
`),
|
|
73
|
+
) as Array<{
|
|
74
|
+
forum_id: number
|
|
75
|
+
forum_title: string
|
|
76
|
+
forum_slug: string
|
|
77
|
+
thread_id: number | null
|
|
78
|
+
thread_title: string | null
|
|
79
|
+
thread_slug: string | null
|
|
80
|
+
title: string
|
|
81
|
+
message: string
|
|
82
|
+
body_format: number
|
|
83
|
+
updated_at: Date | string
|
|
84
|
+
}>
|
|
85
|
+
|
|
86
|
+
return rows.map((row) => ({
|
|
87
|
+
forumId: Number(row.forum_id),
|
|
88
|
+
forumTitle: row.forum_title,
|
|
89
|
+
forumSlug: row.forum_slug,
|
|
90
|
+
threadId: row.thread_id === null ? null : Number(row.thread_id),
|
|
91
|
+
threadTitle: row.thread_title,
|
|
92
|
+
threadSlug: row.thread_slug,
|
|
93
|
+
title: row.title,
|
|
94
|
+
message: sourceAsMarkdown(row.message, Number(row.body_format)),
|
|
95
|
+
updatedAt: new Date(row.updated_at),
|
|
96
|
+
}))
|
|
97
|
+
}
|
|
59
98
|
}
|