@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 +21 -0
- package/package.json +25 -0
- package/src/index.ts +22 -0
- package/src/modes.ts +18 -0
- package/src/notifier.ts +109 -0
- package/src/types.ts +44 -0
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
|
+
}
|
package/src/notifier.ts
ADDED
|
@@ -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
|
+
}
|