@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.
Files changed (41) hide show
  1. package/migrations/0060_webhook_payload_format.sql +1 -0
  2. package/migrations/0061_task_schedule.sql +1 -0
  3. package/migrations/0062_pretty_zombie.sql +12 -0
  4. package/migrations/0063_board_digest.sql +3 -0
  5. package/migrations/0064_redundant_lady_bullseye.sql +2 -0
  6. package/migrations/0065_serious_puff_adder.sql +1 -0
  7. package/migrations/meta/0061_snapshot.json +9515 -0
  8. package/migrations/meta/0062_snapshot.json +9617 -0
  9. package/migrations/meta/0063_snapshot.json +9653 -0
  10. package/migrations/meta/0064_snapshot.json +9667 -0
  11. package/migrations/meta/0065_snapshot.json +9674 -0
  12. package/migrations/meta/_journal.json +42 -0
  13. package/package.json +31 -30
  14. package/src/account-repos.ts +32 -15
  15. package/src/api-repo.ts +192 -21
  16. package/src/attachment-repo.ts +26 -0
  17. package/src/board-digest-repo.ts +45 -0
  18. package/src/content-counters.ts +14 -0
  19. package/src/discovery-repo.ts +22 -0
  20. package/src/draft-repo.ts +40 -1
  21. package/src/feed-token-repo.ts +103 -0
  22. package/src/forum-admin-repo.ts +43 -18
  23. package/src/index.ts +10 -0
  24. package/src/member-settings-repo.ts +25 -1
  25. package/src/moderation-queue.ts +61 -21
  26. package/src/plugin-data.ts +19 -0
  27. package/src/plugin-grants.ts +83 -0
  28. package/src/plugin-role.ts +79 -0
  29. package/src/post-repo.ts +26 -0
  30. package/src/post-writes.ts +32 -2
  31. package/src/presence-repo.ts +29 -1
  32. package/src/read-state-repo.ts +21 -1
  33. package/src/report-repo.ts +151 -7
  34. package/src/schema/content.ts +5 -0
  35. package/src/schema/identity.ts +33 -1
  36. package/src/schema/platform.ts +2 -0
  37. package/src/search-repo.ts +48 -17
  38. package/src/task-repo.ts +19 -7
  39. package/src/thread-writes.ts +7 -5
  40. package/src/upgrade-repo.ts +15 -7
  41. 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.30.1",
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.30.1",
26
- "@meith/admin": "0.30.1",
27
- "@meith/antispam": "0.30.1",
28
- "@meith/api": "0.30.1",
29
- "@meith/authorization": "0.30.1",
30
- "@meith/attachments": "0.30.1",
31
- "@meith/avatars": "0.30.1",
32
- "@meith/core": "0.30.1",
33
- "@meith/drafts": "0.30.1",
34
- "@meith/events": "0.30.1",
35
- "@meith/forums": "0.30.1",
36
- "@meith/groups": "0.30.1",
37
- "@meith/i18n": "0.30.1",
38
- "@meith/markdown": "0.30.1",
39
- "@meith/marketplace": "0.30.1",
40
- "@meith/messages": "0.30.1",
41
- "@meith/moderation": "0.30.1",
42
- "@meith/notifications": "0.30.1",
43
- "@meith/plugin-kit": "0.30.1",
44
- "@meith/polls": "0.30.1",
45
- "@meith/profile-fields": "0.30.1",
46
- "@meith/relations": "0.30.1",
47
- "@meith/reputation": "0.30.1",
48
- "@meith/settings": "0.30.1",
49
- "@meith/search": "0.30.1",
50
- "@meith/signatures": "0.30.1",
51
- "@meith/subscriptions": "0.30.1",
52
- "@meith/tasks": "0.30.1",
53
- "@meith/threads": "0.30.1"
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",
@@ -98,21 +98,38 @@ export class PostgresAccountRepository implements AccountRepository {
98
98
  }
99
99
 
100
100
  async create(input: NewAccount): Promise<AccountRecord> {
101
- const rows = await this.db
102
- .insert(users)
103
- .values({
104
- username: input.username,
105
- usernameLower: input.usernameLower,
106
- email: input.email,
107
- emailLower: input.emailLower,
108
- passwordHash: input.passwordHash,
109
- passwordAlgo: input.passwordAlgo,
110
- state: input.state,
111
- primaryGroupId: input.primaryGroupId,
112
- registrationIpPrefix: input.registrationIpPrefix ?? null,
113
- })
114
- .returning(ACCOUNT_COLUMNS)
115
- return toAccountRecord(rows[0]!)
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<number> {
193
- const rows = (await this.db.execute(sql`
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
- select w.id, ${deliveryId}, ${topic}, ${JSON.stringify(payload)}::jsonb
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
- `)) as unknown as unknown[]
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 = (await this.db.execute(sql`
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
- `)) as unknown as {
221
- id: number
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 = (await this.db.execute(sql`
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
- `)) as unknown as unknown[]
443
+ `),
444
+ )
274
445
  return rows.length > 0
275
446
  }
276
447
  }
@@ -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
+ }
@@ -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> {
@@ -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
  }