@meith/board-digest 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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jordan Harrison and the Meith contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@meith/board-digest",
3
+ "version": "0.32.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/meith-dev/meith.git",
8
+ "directory": "packages/board-digest"
9
+ },
10
+ "type": "module",
11
+ "main": "./src/index.ts",
12
+ "types": "./src/index.ts",
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.*",
16
+ "!src/**/*.type-test.*"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@meith/core": "0.32.0",
23
+ "@meith/subscriptions": "0.32.0"
24
+ }
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export {
2
+ BOARD_DIGEST_CADENCE_INTERVAL_MS,
3
+ BOARD_DIGEST_CADENCES,
4
+ BOARD_DIGEST_DEFAULT_CADENCE,
5
+ type BoardDigestCadence,
6
+ isBoardDigestCadence,
7
+ parseBoardDigestCadence,
8
+ } from './modes'
9
+ export {
10
+ BoardDigestNotifier,
11
+ MAX_MEMBERS_PER_RUN,
12
+ MAX_THREADS_CONSIDERED,
13
+ MAX_THREADS_IN_BOARD_DIGEST,
14
+ type RunOutcome,
15
+ } from './notifier'
16
+ export type {
17
+ BoardDigestContentSource,
18
+ BoardDigestNotifierPort,
19
+ BoardDigestRepository,
20
+ BoardDigestThread,
21
+ EligibleMember,
22
+ } from './types'
package/src/modes.ts ADDED
@@ -0,0 +1,18 @@
1
+ export const BOARD_DIGEST_CADENCES = ['weekly', 'monthly'] as const
2
+
3
+ export type BoardDigestCadence = (typeof BOARD_DIGEST_CADENCES)[number]
4
+
5
+ export const BOARD_DIGEST_DEFAULT_CADENCE: BoardDigestCadence = 'weekly'
6
+
7
+ export const BOARD_DIGEST_CADENCE_INTERVAL_MS: Readonly<Record<BoardDigestCadence, number>> = {
8
+ weekly: 7 * 24 * 60 * 60 * 1000,
9
+ monthly: 30 * 24 * 60 * 60 * 1000,
10
+ }
11
+
12
+ export function isBoardDigestCadence(value: string): value is BoardDigestCadence {
13
+ return (BOARD_DIGEST_CADENCES as readonly string[]).includes(value)
14
+ }
15
+
16
+ export function parseBoardDigestCadence(value: string): BoardDigestCadence | null {
17
+ return isBoardDigestCadence(value) ? value : null
18
+ }
@@ -0,0 +1,109 @@
1
+ import { mintUnsubscribeToken } from '@meith/subscriptions'
2
+
3
+ import { BOARD_DIGEST_CADENCE_INTERVAL_MS, type BoardDigestCadence } from './modes'
4
+ import type {
5
+ BoardDigestContentSource,
6
+ BoardDigestNotifierPort,
7
+ BoardDigestRepository,
8
+ BoardDigestThread,
9
+ } from './types'
10
+
11
+ export const MAX_MEMBERS_PER_RUN = 50
12
+
13
+ export const MAX_THREADS_CONSIDERED = 50
14
+
15
+ export const MAX_THREADS_IN_BOARD_DIGEST = 10
16
+
17
+ const DAY_MS = 24 * 60 * 60 * 1000
18
+
19
+ export interface RunOutcome {
20
+ readonly notified: number
21
+ readonly considered: number
22
+ }
23
+
24
+ export class BoardDigestNotifier {
25
+ private readonly repository: BoardDigestRepository
26
+ private readonly content: BoardDigestContentSource
27
+ private readonly notifications: BoardDigestNotifierPort
28
+ private readonly secret: string | null
29
+ private readonly now: () => Date
30
+
31
+ constructor(deps: {
32
+ repository: BoardDigestRepository
33
+ content: BoardDigestContentSource
34
+ notifications: BoardDigestNotifierPort
35
+ unsubscribeSecret?: string | null
36
+ now?: () => Date
37
+ }) {
38
+ this.repository = deps.repository
39
+ this.content = deps.content
40
+ this.notifications = deps.notifications
41
+ this.secret = deps.unsubscribeSecret ?? null
42
+ this.now = deps.now ?? (() => new Date())
43
+ }
44
+
45
+ async run(
46
+ cadence: BoardDigestCadence,
47
+ lapsedThresholdDays: number,
48
+ limit = MAX_MEMBERS_PER_RUN,
49
+ signal?: AbortSignal,
50
+ ): Promise<RunOutcome> {
51
+ const at = this.now()
52
+ const dueBefore = new Date(at.getTime() - BOARD_DIGEST_CADENCE_INTERVAL_MS[cadence])
53
+ const lapsedBefore = new Date(at.getTime() - lapsedThresholdDays * DAY_MS)
54
+
55
+ const members = await this.repository.dueMembers({ cadence, dueBefore, lapsedBefore, limit })
56
+
57
+ let notified = 0
58
+ let considered = 0
59
+
60
+ for (const member of members) {
61
+ if (signal?.aborted === true) break
62
+ considered += 1
63
+
64
+ try {
65
+ const threads = await this.content.threadsActiveSince(
66
+ member.userId,
67
+ member.lastActiveAt,
68
+ MAX_THREADS_CONSIDERED,
69
+ )
70
+ if (threads.length === 0) continue
71
+
72
+ await this.notifications.raise({
73
+ userId: member.userId,
74
+ kind: 'board.digest',
75
+ data: {
76
+ cadence,
77
+ threadCount: threads.length,
78
+ threads: threads.slice(0, MAX_THREADS_IN_BOARD_DIGEST).map(threadPayload),
79
+ more: Math.max(0, threads.length - MAX_THREADS_IN_BOARD_DIGEST),
80
+ unsubscribe: this.token(member.userId),
81
+ },
82
+ href: '/notifications/preferences',
83
+ dedupeKey: null,
84
+ })
85
+
86
+ await this.repository.recordDigestRun({ userId: member.userId, at })
87
+ notified += 1
88
+ } catch {}
89
+ }
90
+
91
+ return { notified, considered }
92
+ }
93
+
94
+ private token(userId: number): string | null {
95
+ return this.secret === null
96
+ ? null
97
+ : mintUnsubscribeToken({ userId, scope: 'board-digest', targetId: 0 }, this.secret)
98
+ }
99
+ }
100
+
101
+ function threadPayload(thread: BoardDigestThread): Record<string, unknown> {
102
+ return {
103
+ title: thread.title,
104
+ href: thread.href,
105
+ forumTitle: thread.forumTitle,
106
+ replyCount: thread.replyCount,
107
+ lastAuthor: thread.lastAuthor,
108
+ }
109
+ }
package/src/types.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { BoardDigestCadence } from './modes'
2
+
3
+ export interface BoardDigestThread {
4
+ readonly threadId: number
5
+ readonly title: string
6
+ readonly href: string
7
+ readonly forumTitle: string
8
+ readonly replyCount: number
9
+ readonly lastAuthor: string | null
10
+ }
11
+
12
+ export interface EligibleMember {
13
+ readonly userId: number
14
+ readonly lastActiveAt: Date
15
+ }
16
+
17
+ export interface BoardDigestRepository {
18
+ dueMembers(input: {
19
+ readonly cadence: BoardDigestCadence
20
+ readonly dueBefore: Date
21
+ readonly lapsedBefore: Date
22
+ readonly limit: number
23
+ }): Promise<readonly EligibleMember[]>
24
+
25
+ recordDigestRun(input: { readonly userId: number; readonly at: Date }): Promise<void>
26
+ }
27
+
28
+ export interface BoardDigestContentSource {
29
+ threadsActiveSince(
30
+ userId: number,
31
+ since: Date,
32
+ limit: number,
33
+ ): Promise<readonly BoardDigestThread[]>
34
+ }
35
+
36
+ export interface BoardDigestNotifierPort {
37
+ raise(input: {
38
+ readonly userId: number
39
+ readonly kind: 'board.digest'
40
+ readonly data: Record<string, unknown>
41
+ readonly href: string | null
42
+ readonly dedupeKey: string | null
43
+ }): Promise<void>
44
+ }