@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/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
+ }