@meith/notifications 0.16.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 +165 -0
- package/package.json +26 -0
- package/src/deliver-push.ts +116 -0
- package/src/deliver.ts +48 -0
- package/src/index.ts +64 -0
- package/src/kinds.ts +189 -0
- package/src/mail.ts +73 -0
- package/src/push.ts +236 -0
- package/src/render.ts +275 -0
- package/src/service.ts +276 -0
- package/src/types.ts +133 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { ValidationError } from '@meith/core'
|
|
2
|
+
import { EN_CATALOG, sourceTranslator, type Translator } from '@meith/i18n'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
NOTIFICATION_KINDS,
|
|
6
|
+
type NotificationAudience,
|
|
7
|
+
type RegisteredNotificationKind,
|
|
8
|
+
} from './kinds'
|
|
9
|
+
import { type NotificationView, renderNotification } from './render'
|
|
10
|
+
import type {
|
|
11
|
+
NotificationChannel,
|
|
12
|
+
NotificationData,
|
|
13
|
+
NotificationRepository,
|
|
14
|
+
PushSubscriptionRecord,
|
|
15
|
+
RaiseResult,
|
|
16
|
+
SavePushSubscriptionInput,
|
|
17
|
+
} from './types'
|
|
18
|
+
|
|
19
|
+
export const NOTIFICATIONS_PAGE_SIZE = 25
|
|
20
|
+
|
|
21
|
+
export const MAX_STAFF_FANOUT = 50
|
|
22
|
+
|
|
23
|
+
export interface NotificationPreferenceView {
|
|
24
|
+
readonly kind: string
|
|
25
|
+
readonly title: string
|
|
26
|
+
readonly description: string
|
|
27
|
+
readonly titleKey?: string
|
|
28
|
+
readonly descriptionKey?: string
|
|
29
|
+
readonly email: boolean
|
|
30
|
+
readonly push: boolean
|
|
31
|
+
readonly isDefault: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where a plugin gets to see a notification. `before` may rewrite it or return
|
|
36
|
+
* null to drop it; `created` is told what was stored and cannot change it.
|
|
37
|
+
*/
|
|
38
|
+
export interface NotificationAudit {
|
|
39
|
+
readonly before: (input: {
|
|
40
|
+
readonly userId: number
|
|
41
|
+
readonly kind: string
|
|
42
|
+
readonly subjectText: string
|
|
43
|
+
readonly href: string
|
|
44
|
+
}) => Promise<{
|
|
45
|
+
readonly userId: number
|
|
46
|
+
readonly kind: string
|
|
47
|
+
readonly subjectText: string
|
|
48
|
+
readonly href: string
|
|
49
|
+
} | null>
|
|
50
|
+
readonly created: (input: {
|
|
51
|
+
readonly notificationId: number
|
|
52
|
+
readonly userId: number
|
|
53
|
+
}) => Promise<void>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const NO_NOTIFICATION_AUDIT: NotificationAudit = {
|
|
57
|
+
before: async (input) => input,
|
|
58
|
+
created: async () => {},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A notification suppressed by a plugin. It reports the same shape a coalesced
|
|
63
|
+
* one does, so nothing downstream has to learn a third outcome.
|
|
64
|
+
*/
|
|
65
|
+
const DROPPED: RaiseResult = {
|
|
66
|
+
notificationId: 0,
|
|
67
|
+
coalesced: true,
|
|
68
|
+
emailQueued: false,
|
|
69
|
+
pushQueued: false,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function subjectTextOf(data: NotificationData): string {
|
|
73
|
+
const subject = data.subject ?? data.subjectKey ?? ''
|
|
74
|
+
return typeof subject === 'string' ? subject : String(subject)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class NotificationService {
|
|
78
|
+
private readonly repository: NotificationRepository
|
|
79
|
+
private readonly now: () => Date
|
|
80
|
+
private readonly kinds: ReadonlyMap<string, RegisteredNotificationKind>
|
|
81
|
+
private readonly audit: NotificationAudit
|
|
82
|
+
|
|
83
|
+
constructor(deps: {
|
|
84
|
+
notifications: NotificationRepository
|
|
85
|
+
now?: () => Date
|
|
86
|
+
extraKinds?: readonly RegisteredNotificationKind[]
|
|
87
|
+
audit?: NotificationAudit
|
|
88
|
+
}) {
|
|
89
|
+
this.repository = deps.notifications
|
|
90
|
+
this.now = deps.now ?? (() => new Date())
|
|
91
|
+
this.audit = deps.audit ?? NO_NOTIFICATION_AUDIT
|
|
92
|
+
|
|
93
|
+
const kinds = new Map<string, RegisteredNotificationKind>(
|
|
94
|
+
NOTIFICATION_KINDS.map((kind) => [kind.id, kind as RegisteredNotificationKind]),
|
|
95
|
+
)
|
|
96
|
+
for (const extra of deps.extraKinds ?? []) {
|
|
97
|
+
if (!extra.id.startsWith('plugin.')) {
|
|
98
|
+
throw new ValidationError(
|
|
99
|
+
`Registered notification kind "${extra.id}" must be namespaced plugin.…`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
if (kinds.has(extra.id)) {
|
|
103
|
+
throw new ValidationError(`Notification kind "${extra.id}" is declared twice.`)
|
|
104
|
+
}
|
|
105
|
+
kinds.set(extra.id, extra)
|
|
106
|
+
}
|
|
107
|
+
this.kinds = kinds
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async raise(input: {
|
|
111
|
+
readonly userId: number
|
|
112
|
+
readonly kind: string
|
|
113
|
+
readonly data: NotificationData
|
|
114
|
+
readonly href?: string | null
|
|
115
|
+
readonly dedupeKey?: string | null
|
|
116
|
+
}): Promise<RaiseResult> {
|
|
117
|
+
const spec = this.kinds.get(input.kind)
|
|
118
|
+
if (spec === undefined) throw new ValidationError(`Unknown notification kind: ${input.kind}`)
|
|
119
|
+
|
|
120
|
+
const proposed = await this.audit.before({
|
|
121
|
+
userId: input.userId,
|
|
122
|
+
kind: input.kind,
|
|
123
|
+
subjectText: subjectTextOf(input.data),
|
|
124
|
+
href: input.href ?? '',
|
|
125
|
+
})
|
|
126
|
+
if (proposed === null) return DROPPED
|
|
127
|
+
|
|
128
|
+
const wanted = await this.wanted(proposed.userId, proposed.kind)
|
|
129
|
+
|
|
130
|
+
const result = await this.repository.raise({
|
|
131
|
+
userId: proposed.userId,
|
|
132
|
+
kind: proposed.kind,
|
|
133
|
+
data: input.data,
|
|
134
|
+
href: proposed.href === '' ? null : proposed.href,
|
|
135
|
+
dedupeKey: input.dedupeKey ?? null,
|
|
136
|
+
email: wanted.email,
|
|
137
|
+
push: wanted.push,
|
|
138
|
+
at: this.now(),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
if (!result.coalesced) {
|
|
142
|
+
await this.audit.created({ notificationId: result.notificationId, userId: proposed.userId })
|
|
143
|
+
}
|
|
144
|
+
return result
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async raiseForAdministrators(input: {
|
|
148
|
+
readonly kind: string
|
|
149
|
+
readonly data: NotificationData
|
|
150
|
+
readonly href?: string | null
|
|
151
|
+
readonly dedupeKey?: string | null
|
|
152
|
+
}): Promise<{ raised: number }> {
|
|
153
|
+
const recipients = await this.repository.administratorIds(MAX_STAFF_FANOUT)
|
|
154
|
+
|
|
155
|
+
let raised = 0
|
|
156
|
+
for (const userId of recipients) {
|
|
157
|
+
try {
|
|
158
|
+
await this.raise({ ...input, userId })
|
|
159
|
+
raised += 1
|
|
160
|
+
} catch {
|
|
161
|
+
/* ignore */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { raised }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async list(
|
|
168
|
+
userId: number,
|
|
169
|
+
options: { readonly after?: string; readonly offset?: number } = {},
|
|
170
|
+
t: Translator = sourceTranslator(EN_CATALOG),
|
|
171
|
+
): Promise<{ rows: readonly NotificationView[]; nextCursor?: string }> {
|
|
172
|
+
const page = await this.repository.listFor(userId, {
|
|
173
|
+
limit: NOTIFICATIONS_PAGE_SIZE,
|
|
174
|
+
...(options.after === undefined ? {} : { after: options.after }),
|
|
175
|
+
...(options.offset === undefined ? {} : { offset: options.offset }),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
rows: page.rows.map((row) => renderNotification(row, t)),
|
|
180
|
+
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async count(userId: number): Promise<number> {
|
|
185
|
+
return this.repository.countFor(userId)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async unreadCount(userId: number): Promise<number> {
|
|
189
|
+
return this.repository.unreadCount(userId)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async markRead(userId: number, notificationId: number): Promise<boolean> {
|
|
193
|
+
return this.repository.markRead(userId, notificationId)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async markAllRead(userId: number): Promise<number> {
|
|
197
|
+
return this.repository.markAllRead(userId)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
configurableKinds(
|
|
201
|
+
audience: NotificationAudience,
|
|
202
|
+
channel: NotificationChannel = 'email',
|
|
203
|
+
): readonly RegisteredNotificationKind[] {
|
|
204
|
+
return [...this.kinds.values()].filter(
|
|
205
|
+
(kind) =>
|
|
206
|
+
kind.audience === audience &&
|
|
207
|
+
(channel === 'email' ? kind.emailConfigurable : kind.pushConfigurable),
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async preferences(
|
|
212
|
+
userId: number,
|
|
213
|
+
audience: NotificationAudience,
|
|
214
|
+
): Promise<readonly NotificationPreferenceView[]> {
|
|
215
|
+
const stored = await this.repository.preferencesFor(userId)
|
|
216
|
+
|
|
217
|
+
return this.configurableKinds(audience).map((spec) => {
|
|
218
|
+
const override = stored.get(spec.id)
|
|
219
|
+
return {
|
|
220
|
+
kind: spec.id,
|
|
221
|
+
title: spec.title,
|
|
222
|
+
description: spec.description,
|
|
223
|
+
...(spec.titleKey === undefined ? {} : { titleKey: spec.titleKey }),
|
|
224
|
+
...(spec.descriptionKey === undefined ? {} : { descriptionKey: spec.descriptionKey }),
|
|
225
|
+
email: override?.email ?? spec.emailByDefault,
|
|
226
|
+
push: spec.pushConfigurable ? (override?.push ?? spec.pushByDefault) : false,
|
|
227
|
+
isDefault: override === undefined,
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async savePreferences(
|
|
233
|
+
userId: number,
|
|
234
|
+
audience: NotificationAudience,
|
|
235
|
+
enabled: readonly string[],
|
|
236
|
+
channel: NotificationChannel = 'email',
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
const checked = new Set(enabled)
|
|
239
|
+
const entries = new Map<string, boolean>()
|
|
240
|
+
|
|
241
|
+
for (const spec of this.configurableKinds(audience, channel)) {
|
|
242
|
+
entries.set(spec.id, checked.has(spec.id))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await this.repository.savePreferences(userId, channel, entries)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async pushSubscriptions(userId: number): Promise<readonly PushSubscriptionRecord[]> {
|
|
249
|
+
return this.repository.pushSubscriptionsFor(userId)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async countPushSubscriptions(userId: number): Promise<number> {
|
|
253
|
+
return this.repository.countPushSubscriptions(userId)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async subscribeToPush(input: Omit<SavePushSubscriptionInput, 'at'>): Promise<void> {
|
|
257
|
+
await this.repository.savePushSubscription({ ...input, at: this.now() })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async unsubscribeFromPush(userId: number, endpoint: string): Promise<boolean> {
|
|
261
|
+
return this.repository.removePushSubscription(userId, endpoint)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async wanted(userId: number, kind: string): Promise<{ email: boolean; push: boolean }> {
|
|
265
|
+
const spec = this.kinds.get(kind)
|
|
266
|
+
if (spec === undefined) return { email: false, push: false }
|
|
267
|
+
|
|
268
|
+
const stored = (await this.repository.preferencesFor(userId)).get(kind)
|
|
269
|
+
const asked = spec.pushConfigurable ? (stored?.push ?? spec.pushByDefault) : spec.pushByDefault
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
email: stored?.email ?? spec.emailByDefault,
|
|
273
|
+
push: asked && (await this.repository.countPushSubscriptions(userId)) > 0,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export type NotificationValue =
|
|
2
|
+
| string
|
|
3
|
+
| number
|
|
4
|
+
| boolean
|
|
5
|
+
| null
|
|
6
|
+
| readonly NotificationValue[]
|
|
7
|
+
| { readonly [key: string]: NotificationValue }
|
|
8
|
+
|
|
9
|
+
export type NotificationData = Readonly<Record<string, NotificationValue>>
|
|
10
|
+
|
|
11
|
+
export interface NotificationRecord {
|
|
12
|
+
readonly id: number
|
|
13
|
+
readonly userId: number
|
|
14
|
+
readonly kind: string
|
|
15
|
+
readonly data: NotificationData
|
|
16
|
+
readonly href: string | null
|
|
17
|
+
readonly occurrences: number
|
|
18
|
+
readonly createdAt: Date
|
|
19
|
+
readonly updatedAt: Date
|
|
20
|
+
readonly readAt: Date | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NotificationPage {
|
|
24
|
+
readonly rows: readonly NotificationRecord[]
|
|
25
|
+
readonly nextCursor?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type NotificationChannel = 'email' | 'push'
|
|
29
|
+
|
|
30
|
+
export interface NotificationChannelPreference {
|
|
31
|
+
readonly email: boolean | null
|
|
32
|
+
readonly push: boolean | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PushSubscriptionRecord {
|
|
36
|
+
readonly id: number
|
|
37
|
+
readonly userId: number
|
|
38
|
+
readonly endpoint: string
|
|
39
|
+
readonly p256dh: string
|
|
40
|
+
readonly auth: string
|
|
41
|
+
readonly createdAt: Date
|
|
42
|
+
readonly lastSeenAt: Date
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SavePushSubscriptionInput {
|
|
46
|
+
readonly userId: number
|
|
47
|
+
readonly endpoint: string
|
|
48
|
+
readonly p256dh: string
|
|
49
|
+
readonly auth: string
|
|
50
|
+
readonly at: Date
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DeliverableNotification {
|
|
54
|
+
readonly notification: NotificationRecord
|
|
55
|
+
readonly recipient: {
|
|
56
|
+
readonly userId: number
|
|
57
|
+
readonly username: string
|
|
58
|
+
readonly email: string
|
|
59
|
+
readonly locale: string
|
|
60
|
+
}
|
|
61
|
+
readonly emailEnabled: boolean
|
|
62
|
+
readonly emailSentAt: Date | null
|
|
63
|
+
readonly pushEnabled: boolean
|
|
64
|
+
readonly pushSentAt: Date | null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RaiseInput {
|
|
68
|
+
readonly userId: number
|
|
69
|
+
readonly kind: string
|
|
70
|
+
readonly data: NotificationData
|
|
71
|
+
readonly href?: string | null
|
|
72
|
+
readonly dedupeKey?: string | null
|
|
73
|
+
readonly email: boolean
|
|
74
|
+
readonly push: boolean
|
|
75
|
+
readonly at: Date
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface RaiseResult {
|
|
79
|
+
readonly notificationId: number
|
|
80
|
+
readonly coalesced: boolean
|
|
81
|
+
readonly emailQueued: boolean
|
|
82
|
+
readonly pushQueued: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface NotificationRepository {
|
|
86
|
+
raise(input: RaiseInput): Promise<RaiseResult>
|
|
87
|
+
|
|
88
|
+
listFor(
|
|
89
|
+
userId: number,
|
|
90
|
+
options: {
|
|
91
|
+
readonly limit: number
|
|
92
|
+
readonly after?: string
|
|
93
|
+
readonly offset?: number
|
|
94
|
+
},
|
|
95
|
+
): Promise<NotificationPage>
|
|
96
|
+
|
|
97
|
+
unreadCount(userId: number): Promise<number>
|
|
98
|
+
|
|
99
|
+
/** Every notification this member has, read or not. */
|
|
100
|
+
countFor(userId: number): Promise<number>
|
|
101
|
+
|
|
102
|
+
markRead(userId: number, notificationId: number): Promise<boolean>
|
|
103
|
+
|
|
104
|
+
markAllRead(userId: number): Promise<number>
|
|
105
|
+
|
|
106
|
+
preferencesFor(userId: number): Promise<ReadonlyMap<string, NotificationChannelPreference>>
|
|
107
|
+
|
|
108
|
+
savePreferences(
|
|
109
|
+
userId: number,
|
|
110
|
+
channel: NotificationChannel,
|
|
111
|
+
entries: ReadonlyMap<string, boolean>,
|
|
112
|
+
): Promise<void>
|
|
113
|
+
|
|
114
|
+
findForDelivery(notificationId: number): Promise<DeliverableNotification | null>
|
|
115
|
+
|
|
116
|
+
markEmailSent(notificationId: number, at: Date): Promise<void>
|
|
117
|
+
|
|
118
|
+
markPushSent(notificationId: number, at: Date): Promise<void>
|
|
119
|
+
|
|
120
|
+
savePushSubscription(input: SavePushSubscriptionInput): Promise<void>
|
|
121
|
+
|
|
122
|
+
removePushSubscription(userId: number, endpoint: string): Promise<boolean>
|
|
123
|
+
|
|
124
|
+
pushSubscriptionsFor(userId: number): Promise<readonly PushSubscriptionRecord[]>
|
|
125
|
+
|
|
126
|
+
countPushSubscriptions(userId: number): Promise<number>
|
|
127
|
+
|
|
128
|
+
prunePushSubscription(id: number): Promise<void>
|
|
129
|
+
|
|
130
|
+
touchPushSubscription(id: number, at: Date): Promise<void>
|
|
131
|
+
|
|
132
|
+
administratorIds(limit: number): Promise<readonly number[]>
|
|
133
|
+
}
|