@strav/notification 1.0.0-alpha.25

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 (43) hide show
  1. package/README.md +36 -0
  2. package/package.json +48 -0
  3. package/src/drivers/broadcast/broadcast_config.ts +21 -0
  4. package/src/drivers/broadcast/broadcast_notification_driver.ts +105 -0
  5. package/src/drivers/broadcast/broadcast_notification_provider.ts +18 -0
  6. package/src/drivers/broadcast/index.ts +7 -0
  7. package/src/drivers/database/apply_notification_migration.ts +44 -0
  8. package/src/drivers/database/database_config.ts +7 -0
  9. package/src/drivers/database/database_notification_driver.ts +72 -0
  10. package/src/drivers/database/database_notification_provider.ts +44 -0
  11. package/src/drivers/database/index.ts +16 -0
  12. package/src/drivers/database/notification_record.ts +22 -0
  13. package/src/drivers/database/notification_repository.ts +67 -0
  14. package/src/drivers/database/schemas/notification_schema.ts +44 -0
  15. package/src/drivers/database/tenanted/apply_tenanted_notification_migration.ts +19 -0
  16. package/src/drivers/database/tenanted/index.ts +10 -0
  17. package/src/drivers/database/tenanted/schemas/tenanted_notification_schema.ts +28 -0
  18. package/src/drivers/database/tenanted/tenanted_notification_record.ts +15 -0
  19. package/src/drivers/database/tenanted/tenanted_notification_repository.ts +63 -0
  20. package/src/drivers/log/index.ts +6 -0
  21. package/src/drivers/log/log_config.ts +12 -0
  22. package/src/drivers/log/log_notification_driver.ts +72 -0
  23. package/src/drivers/log/log_notification_provider.ts +29 -0
  24. package/src/drivers/mail/index.ts +6 -0
  25. package/src/drivers/mail/mail_config.ts +7 -0
  26. package/src/drivers/mail/mail_notification_driver.ts +66 -0
  27. package/src/drivers/mail/mail_notification_provider.ts +18 -0
  28. package/src/drivers/mock.ts +48 -0
  29. package/src/drivers/unsupported.ts +15 -0
  30. package/src/drivers/webhook/index.ts +10 -0
  31. package/src/drivers/webhook/sign.ts +47 -0
  32. package/src/drivers/webhook/webhook_config.ts +43 -0
  33. package/src/drivers/webhook/webhook_notification_driver.ts +172 -0
  34. package/src/drivers/webhook/webhook_notification_provider.ts +44 -0
  35. package/src/index.ts +38 -0
  36. package/src/notifiable.ts +22 -0
  37. package/src/notification.ts +26 -0
  38. package/src/notification_config.ts +26 -0
  39. package/src/notification_driver.ts +40 -0
  40. package/src/notification_error.ts +62 -0
  41. package/src/notification_manager.ts +135 -0
  42. package/src/notification_provider.ts +42 -0
  43. package/src/types.ts +34 -0
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Public API of @strav/notification.
2
+ //
3
+ // V1: NotificationManager facade + NotificationDriver interface +
4
+ // BaseNotification abstract class + Notifiable interface + four
5
+ // channel drivers under subpaths (./mail, ./database, ./log, ./webhook).
6
+ // Broadcast / SSE / Discord / SMS channels deferred to future slices.
7
+
8
+ export {
9
+ MockNotificationDriver,
10
+ type MockNotificationRecord,
11
+ mockNotificationDriverFactory,
12
+ } from './drivers/mock.ts'
13
+ export type { Notifiable } from './notifiable.ts'
14
+ export { BaseNotification } from './notification.ts'
15
+ export type {
16
+ ChannelConfig,
17
+ NotificationConfig,
18
+ } from './notification_config.ts'
19
+ export type {
20
+ NotificationDriver,
21
+ NotificationDriverFactory,
22
+ } from './notification_driver.ts'
23
+ export {
24
+ NotificationConfigError,
25
+ NotificationDeliveryError,
26
+ NotificationError,
27
+ UnknownChannelError,
28
+ } from './notification_error.ts'
29
+ export {
30
+ NotificationManager,
31
+ type NotificationManagerOptions,
32
+ } from './notification_manager.ts'
33
+ export { NotificationProvider } from './notification_provider.ts'
34
+ export type {
35
+ NotificationContext,
36
+ NotificationDeliveryResult,
37
+ NotificationDispatchResult,
38
+ } from './types.ts'
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `Notifiable` — the minimum a notification recipient must expose.
3
+ *
4
+ * Apps' domain models implement this interface (or extend it via mixin
5
+ * / Repository pattern). Channel-specific data lives on the domain
6
+ * shape: a `User` notifiable might have `email: string` for the mail
7
+ * channel, `phone: string` for an SMS channel, and any per-channel
8
+ * routing-preference fields the app cares about.
9
+ *
10
+ * The framework stays out of the routing decision — each notification's
11
+ * `via(notifiable)` returns the channels to dispatch through, and each
12
+ * channel reads what it needs off the notifiable directly.
13
+ */
14
+
15
+ export interface Notifiable {
16
+ /** Identity. Channels persist this on delivery rows so apps can resolve back. */
17
+ readonly id: string | number
18
+ /** Class / type name. Channels record this alongside the id for polymorphic resolution. */
19
+ readonly notifiableType?: string
20
+ /** Apps add channel-specific fields directly: `email`, `phone`, `preferences`, …. */
21
+ [key: string]: unknown
22
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `BaseNotification` — apps subclass this to define a notification.
3
+ *
4
+ * Two responsibilities the subclass owns:
5
+ *
6
+ * 1. `via(notifiable)` — return the channel names the manager
7
+ * fan-outs to. Apps that pre-load a user's channel preferences
8
+ * branch here (e.g. `notifiable.preferences.email === true`).
9
+ *
10
+ * 2. One `to<Channel>(notifiable)` hook per channel the notification
11
+ * supports. The hook returns the channel's input shape (a `Message`
12
+ * for mail, a `NotificationPayload` for database, etc.). Channels
13
+ * whose hook isn't implemented get skipped — no runtime error.
14
+ *
15
+ * Hooks aren't declared on the base class because each channel knows
16
+ * its own input type and reaches for the named method at dispatch time.
17
+ * Apps that want compile-time hook enforcement extend a per-channel
18
+ * mixin (out of scope for v1 — bring your own discipline for now).
19
+ */
20
+
21
+ import type { Notifiable } from './notifiable.ts'
22
+
23
+ export abstract class BaseNotification {
24
+ /** Channel names the manager fan-outs to. Apps override. */
25
+ abstract via(notifiable: Notifiable): readonly string[]
26
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Notification configuration shape — what `config.notification` looks
3
+ * like. Mirrors the manager-pattern config used by `@strav/payment`
4
+ * and `@strav/social`: a `default` channel key + a `channels` map
5
+ * keyed by name. Each channel entry carries its driver + driver-
6
+ * specific options.
7
+ *
8
+ * `default` is OPTIONAL on this manager: notifications route per-call
9
+ * via `via(notifiable)`, not via a single configured default. Apps
10
+ * set `default` only when they want `manager.use()` (no arg) to
11
+ * resolve to a specific channel.
12
+ */
13
+
14
+ export interface NotificationConfig {
15
+ /** Optional default channel name — must exist in `channels` when set. */
16
+ default?: string
17
+ /** Channel registry. Each entry is one configured backend. */
18
+ channels: Record<string, ChannelConfig>
19
+ }
20
+
21
+ export interface ChannelConfig {
22
+ /** Driver identifier — matches a registered factory (`mail`, `database`, `log`, or custom). */
23
+ driver: string
24
+ /** Free-form driver-specific fields. */
25
+ [key: string]: unknown
26
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `NotificationDriver` — the contract every channel implements.
3
+ *
4
+ * One driver instance per configured channel. The manager calls
5
+ * `send(notifiable, notification, context)` once per channel that
6
+ * `notification.via(notifiable)` named; drivers either deliver or
7
+ * return `{ delivered: false, error }`.
8
+ *
9
+ * Drivers MAY ignore notifications they can't service (no
10
+ * `to<Channel>` hook, recipient missing channel-required fields) by
11
+ * returning `{ delivered: false }` with no `error` — the manager
12
+ * surfaces this in the dispatch result without treating it as a
13
+ * failure.
14
+ */
15
+
16
+ import type { Notifiable } from './notifiable.ts'
17
+ import type { BaseNotification } from './notification.ts'
18
+ import type { NotificationContext, NotificationDeliveryResult } from './types.ts'
19
+
20
+ export interface NotificationDriver {
21
+ /** Identifier — matches `config.notification.channels` key. */
22
+ readonly name: string
23
+
24
+ send(
25
+ notifiable: Notifiable,
26
+ notification: BaseNotification,
27
+ context: NotificationContext,
28
+ ): Promise<NotificationDeliveryResult>
29
+ }
30
+
31
+ /**
32
+ * Channel factory — apps register custom channels via
33
+ * `manager.extend(name, factory)`. The factory receives the channel's
34
+ * config sub-tree plus an `instanceName` (the key under
35
+ * `config.notification.channels`).
36
+ */
37
+ export type NotificationDriverFactory = (args: {
38
+ instanceName: string
39
+ config: { driver: string; [key: string]: unknown }
40
+ }) => NotificationDriver
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Typed error hierarchy. Mirrors `@strav/payment`'s `PaymentError`
3
+ * shape: a base `NotificationError` extending `StravError`, plus
4
+ * subclasses with specific codes apps can branch on.
5
+ */
6
+
7
+ import { StravError } from '@strav/kernel'
8
+
9
+ export class NotificationError extends StravError {
10
+ constructor(
11
+ message: string,
12
+ options: {
13
+ code?: string
14
+ status?: number
15
+ context?: Record<string, unknown>
16
+ cause?: unknown
17
+ } = {},
18
+ ) {
19
+ super(
20
+ message,
21
+ { code: options.code ?? 'notification.error', status: options.status ?? 500 },
22
+ {
23
+ ...(options.context ? { context: options.context } : {}),
24
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
25
+ },
26
+ )
27
+ }
28
+ }
29
+
30
+ export class NotificationConfigError extends NotificationError {
31
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
32
+ super(message, {
33
+ code: 'notification.config',
34
+ status: 500,
35
+ ...(options.context ? { context: options.context } : {}),
36
+ })
37
+ }
38
+ }
39
+
40
+ export class UnknownChannelError extends NotificationError {
41
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
42
+ super(message, {
43
+ code: 'notification.unknown_channel',
44
+ status: 400,
45
+ ...(options.context ? { context: options.context } : {}),
46
+ })
47
+ }
48
+ }
49
+
50
+ export class NotificationDeliveryError extends NotificationError {
51
+ constructor(
52
+ message: string,
53
+ options: { context?: Record<string, unknown>; cause?: unknown } = {},
54
+ ) {
55
+ super(message, {
56
+ code: 'notification.delivery',
57
+ status: 502,
58
+ ...(options.context ? { context: options.context } : {}),
59
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
60
+ })
61
+ }
62
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `NotificationManager` — the facade apps inject for sending
3
+ * notifications.
4
+ *
5
+ * Two concept clusters:
6
+ *
7
+ * - **Channels.** Apps declare configured channels in
8
+ * `config.notification.channels`. The manager constructs each
9
+ * channel driver lazily on first `use(name)` + memoizes. Custom
10
+ * channels register via `manager.extend(name, factory)`. Tests
11
+ * hand-wire via `manager.useDriver(name, driver)`.
12
+ *
13
+ * - **Fan-out.** `send(notifiable, notification)` calls
14
+ * `notification.via(notifiable)` to get the channel list, then
15
+ * dispatches each channel in order, collecting per-channel
16
+ * `NotificationDeliveryResult`s into a single
17
+ * `NotificationDispatchResult`. Channels that throw are
18
+ * captured into the result (`delivered: false`, `error: ...`)
19
+ * — the manager never rethrows; apps inspect the result.
20
+ *
21
+ * One ULID per send shared across channels (for correlation in
22
+ * downstream logs / persistence). The manager constructs the
23
+ * `NotificationContext` once and threads it through.
24
+ */
25
+
26
+ import { ulid } from '@strav/kernel'
27
+ import type { Notifiable } from './notifiable.ts'
28
+ import type { BaseNotification } from './notification.ts'
29
+ import type { NotificationConfig } from './notification_config.ts'
30
+ import type { NotificationDriver, NotificationDriverFactory } from './notification_driver.ts'
31
+ import { NotificationConfigError, UnknownChannelError } from './notification_error.ts'
32
+ import type {
33
+ NotificationContext,
34
+ NotificationDeliveryResult,
35
+ NotificationDispatchResult,
36
+ } from './types.ts'
37
+
38
+ export interface NotificationManagerOptions {
39
+ config: NotificationConfig
40
+ }
41
+
42
+ export class NotificationManager {
43
+ readonly config: NotificationConfig
44
+
45
+ private readonly drivers = new Map<string, NotificationDriver>()
46
+ private readonly extensions = new Map<string, NotificationDriverFactory>()
47
+
48
+ constructor(options: NotificationManagerOptions) {
49
+ const { config } = options
50
+ if (config.default !== undefined && !config.channels[config.default]) {
51
+ throw new NotificationConfigError(
52
+ `NotificationManager: default channel "${config.default}" is not configured.`,
53
+ {
54
+ context: {
55
+ default: config.default,
56
+ available: Object.keys(config.channels),
57
+ },
58
+ },
59
+ )
60
+ }
61
+ this.config = config
62
+ }
63
+
64
+ // ─── Channel routing ──────────────────────────────────────────────────
65
+
66
+ /** Resolve a channel by name (or the default when omitted). */
67
+ use(name?: string): NotificationDriver {
68
+ const key = name ?? this.config.default
69
+ if (key === undefined) {
70
+ throw new NotificationConfigError(
71
+ 'NotificationManager.use(): no name given and no default channel is configured.',
72
+ )
73
+ }
74
+ const cached = this.drivers.get(key)
75
+ if (cached) return cached
76
+
77
+ const cfg = this.config.channels[key]
78
+ if (!cfg) {
79
+ throw new UnknownChannelError(`NotificationManager: channel "${key}" is not configured.`, {
80
+ context: { requested: key, available: Object.keys(this.config.channels) },
81
+ })
82
+ }
83
+
84
+ const factory = this.extensions.get(cfg.driver)
85
+ if (!factory) {
86
+ throw new UnknownChannelError(
87
+ `NotificationManager: unknown driver "${cfg.driver}" for channel "${key}". Register it via \`manager.extend("${cfg.driver}", factory)\`.`,
88
+ { context: { driver: cfg.driver, available: [...this.extensions.keys()] } },
89
+ )
90
+ }
91
+ const driver = factory({ instanceName: key, config: cfg })
92
+ this.drivers.set(key, driver)
93
+ return driver
94
+ }
95
+
96
+ /** Register a channel driver factory. Adapter packages call this from their ServiceProvider. */
97
+ extend(driverName: string, factory: NotificationDriverFactory): void {
98
+ this.extensions.set(driverName, factory)
99
+ }
100
+
101
+ /** Hand-wire a channel instance under an app-chosen name (tests / one-offs). */
102
+ useDriver(instanceName: string, driver: NotificationDriver): void {
103
+ this.drivers.set(instanceName, driver)
104
+ }
105
+
106
+ // ─── Fan-out ──────────────────────────────────────────────────────────
107
+
108
+ async send(
109
+ notifiable: Notifiable,
110
+ notification: BaseNotification,
111
+ ): Promise<NotificationDispatchResult> {
112
+ const context: NotificationContext = {
113
+ id: ulid(),
114
+ dispatchedAt: new Date(),
115
+ }
116
+ const channels = notification.via(notifiable)
117
+ const deliveries: NotificationDeliveryResult[] = []
118
+
119
+ for (const channelName of channels) {
120
+ try {
121
+ const driver = this.use(channelName)
122
+ const result = await driver.send(notifiable, notification, context)
123
+ deliveries.push(result)
124
+ } catch (err) {
125
+ deliveries.push({
126
+ channel: channelName,
127
+ delivered: false,
128
+ error: err instanceof Error ? err : new Error(String(err)),
129
+ })
130
+ }
131
+ }
132
+
133
+ return { id: context.id, deliveries }
134
+ }
135
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `NotificationProvider` — ServiceProvider that wires
3
+ * `NotificationManager` into the container.
4
+ *
5
+ * Eager construction at boot — a malformed config (missing default
6
+ * channel, unknown channel driver) surfaces at startup, not on first
7
+ * `send()` call.
8
+ *
9
+ * Channel adapter packages register themselves AFTER this provider:
10
+ * each `<Channel>NotificationProvider` declares `dependencies =
11
+ * ['notification']` and calls `manager.extend(name, factory)` from
12
+ * its `boot()` so the channel becomes available without the manager
13
+ * needing to import it.
14
+ */
15
+
16
+ import { type Application, ConfigError, ConfigRepository, ServiceProvider } from '@strav/kernel'
17
+ import type { NotificationConfig } from './notification_config.ts'
18
+ import { NotificationManager } from './notification_manager.ts'
19
+
20
+ export class NotificationProvider extends ServiceProvider {
21
+ override readonly name = 'notification'
22
+ override readonly dependencies = ['config']
23
+
24
+ override register(app: Application): void {
25
+ app.singleton(NotificationManager, (c) => {
26
+ const config = c.resolve(ConfigRepository).get('notification') as
27
+ | NotificationConfig
28
+ | undefined
29
+ if (!config) {
30
+ throw new ConfigError(
31
+ 'NotificationProvider: `config.notification` is missing. Add `config/notification.ts` with at least one channel.',
32
+ )
33
+ }
34
+ return new NotificationManager({ config })
35
+ })
36
+ }
37
+
38
+ override async boot(app: Application): Promise<void> {
39
+ // Force-resolve so config errors surface at boot, not on first call.
40
+ app.resolve(NotificationManager)
41
+ }
42
+ }
package/src/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Per-send metadata. The manager constructs one on every `send()` call
3
+ * and threads it through each channel driver. Apps can use the
4
+ * `idempotencyKey` to deduplicate downstream (e.g. preventing the
5
+ * database channel from inserting two rows for the same retry).
6
+ */
7
+ export interface NotificationContext {
8
+ /** ULID — stable per send, shared across channels. */
9
+ id: string
10
+ dispatchedAt: Date
11
+ /** App-supplied idempotency key. Optional. */
12
+ idempotencyKey?: string
13
+ }
14
+
15
+ /**
16
+ * Outcome a channel driver reports back. The manager aggregates these
17
+ * into a `NotificationDispatchResult` so apps can branch on per-channel
18
+ * success / failure without each driver having to throw.
19
+ */
20
+ export interface NotificationDeliveryResult {
21
+ channel: string
22
+ delivered: boolean
23
+ /** Driver-specific reference (mail message id, database row id, etc.). */
24
+ reference?: string
25
+ /** Set when `delivered === false`. */
26
+ error?: Error
27
+ }
28
+
29
+ export interface NotificationDispatchResult {
30
+ /** Notification ULID — matches `NotificationContext.id`. */
31
+ id: string
32
+ /** One entry per channel attempted, in the order `via()` returned them. */
33
+ deliveries: readonly NotificationDeliveryResult[]
34
+ }