@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.
- package/README.md +36 -0
- package/package.json +48 -0
- package/src/drivers/broadcast/broadcast_config.ts +21 -0
- package/src/drivers/broadcast/broadcast_notification_driver.ts +105 -0
- package/src/drivers/broadcast/broadcast_notification_provider.ts +18 -0
- package/src/drivers/broadcast/index.ts +7 -0
- package/src/drivers/database/apply_notification_migration.ts +44 -0
- package/src/drivers/database/database_config.ts +7 -0
- package/src/drivers/database/database_notification_driver.ts +72 -0
- package/src/drivers/database/database_notification_provider.ts +44 -0
- package/src/drivers/database/index.ts +16 -0
- package/src/drivers/database/notification_record.ts +22 -0
- package/src/drivers/database/notification_repository.ts +67 -0
- package/src/drivers/database/schemas/notification_schema.ts +44 -0
- package/src/drivers/database/tenanted/apply_tenanted_notification_migration.ts +19 -0
- package/src/drivers/database/tenanted/index.ts +10 -0
- package/src/drivers/database/tenanted/schemas/tenanted_notification_schema.ts +28 -0
- package/src/drivers/database/tenanted/tenanted_notification_record.ts +15 -0
- package/src/drivers/database/tenanted/tenanted_notification_repository.ts +63 -0
- package/src/drivers/log/index.ts +6 -0
- package/src/drivers/log/log_config.ts +12 -0
- package/src/drivers/log/log_notification_driver.ts +72 -0
- package/src/drivers/log/log_notification_provider.ts +29 -0
- package/src/drivers/mail/index.ts +6 -0
- package/src/drivers/mail/mail_config.ts +7 -0
- package/src/drivers/mail/mail_notification_driver.ts +66 -0
- package/src/drivers/mail/mail_notification_provider.ts +18 -0
- package/src/drivers/mock.ts +48 -0
- package/src/drivers/unsupported.ts +15 -0
- package/src/drivers/webhook/index.ts +10 -0
- package/src/drivers/webhook/sign.ts +47 -0
- package/src/drivers/webhook/webhook_config.ts +43 -0
- package/src/drivers/webhook/webhook_notification_driver.ts +172 -0
- package/src/drivers/webhook/webhook_notification_provider.ts +44 -0
- package/src/index.ts +38 -0
- package/src/notifiable.ts +22 -0
- package/src/notification.ts +26 -0
- package/src/notification_config.ts +26 -0
- package/src/notification_driver.ts +40 -0
- package/src/notification_error.ts +62 -0
- package/src/notification_manager.ts +135 -0
- package/src/notification_provider.ts +42 -0
- package/src/types.ts +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @strav/notification
|
|
2
|
+
|
|
3
|
+
Multi-channel notifications for Strav 1.0. One fluent surface (`notifications.send(notifiable, notification)`) that fan-outs to ≥1 channel drivers — mail / database / log / webhook / broadcast today; Discord / SMS channels in follow-up slices.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { BaseNotification, type Notifiable, NotificationManager } from '@strav/notification'
|
|
7
|
+
|
|
8
|
+
class InvoicePaid extends BaseNotification {
|
|
9
|
+
override via(): readonly string[] {
|
|
10
|
+
return ['mail', 'database']
|
|
11
|
+
}
|
|
12
|
+
toMail(notifiable: Notifiable) {
|
|
13
|
+
return { to: [notifiable.email], subject: 'Invoice paid', text: '...' }
|
|
14
|
+
}
|
|
15
|
+
toDatabase(_notifiable: Notifiable) {
|
|
16
|
+
return { invoiceId: 'inv_42', amount: 4900 }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const notifications = container.resolve(NotificationManager)
|
|
21
|
+
await notifications.send(alice, new InvoicePaid())
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Canonical docs live in [`docs/notification/README.md`](../../docs/notification/README.md).
|
|
25
|
+
|
|
26
|
+
## What ships in v1
|
|
27
|
+
|
|
28
|
+
| Channel | Subpath | Notes |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| Mail | `@strav/notification/mail` | Wraps `@strav/mail`'s `MailManager.send`. |
|
|
31
|
+
| Database | `@strav/notification/database` | Append-only `notification` ledger + `NotificationRepository` (`unread()` / `markAsRead()`). Tenanted variant under `@strav/notification/tenanted`. |
|
|
32
|
+
| Log | `@strav/notification/log` | Routes through `@strav/kernel`'s `Logger`. Useful for dev + tests. |
|
|
33
|
+
| Webhook | `@strav/notification/webhook` | POSTs a signed JSON envelope (`x-strav-signature: sha256=...` over `${timestamp}.${body}`) to a configured endpoint. Exports `verifyWebhookSignature` for receiver-side validation. |
|
|
34
|
+
| Broadcast | `@strav/notification/broadcast` | Publishes a `BroadcastEvent` via `@strav/broadcast`'s `Broadcaster`. Pairs with `router.sse(...)` so live UI clients receive the same dispatch. |
|
|
35
|
+
|
|
36
|
+
Deferred: Discord, SMS channel drivers. Apps register custom channels via `manager.extend(name, factory)`.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/notification",
|
|
3
|
+
"version": "1.0.0-alpha.25",
|
|
4
|
+
"description": "Strav multi-channel notifications — NotificationManager fan-out across channel drivers (mail / database / log / webhook / broadcast). Manager+drivers shape; new channels register via `manager.extend(name, factory)`. Discord / SMS channels ship in follow-up slices.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./mail": "./src/drivers/mail/index.ts",
|
|
11
|
+
"./database": "./src/drivers/database/index.ts",
|
|
12
|
+
"./log": "./src/drivers/log/index.ts",
|
|
13
|
+
"./webhook": "./src/drivers/webhook/index.ts",
|
|
14
|
+
"./broadcast": "./src/drivers/broadcast/index.ts",
|
|
15
|
+
"./tenanted": "./src/drivers/database/tenanted/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"bun": ">=1.3.14"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@strav/kernel": "1.0.0-alpha.25"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@strav/broadcast": "1.0.0-alpha.25",
|
|
32
|
+
"@strav/database": "1.0.0-alpha.25",
|
|
33
|
+
"@strav/mail": "1.0.0-alpha.25",
|
|
34
|
+
"@types/bun": ">=1.3.14"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"@strav/broadcast": {
|
|
38
|
+
"optional": true
|
|
39
|
+
},
|
|
40
|
+
"@strav/database": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"@strav/mail": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": null
|
|
48
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor-specific config shape for the broadcast channel.
|
|
3
|
+
* Discriminator `driver: 'broadcast'` selects this factory.
|
|
4
|
+
*
|
|
5
|
+
* The channel has no provider-specific knobs — routing is decided
|
|
6
|
+
* per-notification by `toBroadcast(notifiable)`'s returned `channel`
|
|
7
|
+
* field. Apps using multiple broadcast backplanes (rare) wire two
|
|
8
|
+
* channels at the manager level:
|
|
9
|
+
*
|
|
10
|
+
* broadcast: { driver: 'broadcast' }
|
|
11
|
+
* broadcast.alt: { driver: 'broadcast' }
|
|
12
|
+
*
|
|
13
|
+
* …and override `via()` on the notification to pick between them.
|
|
14
|
+
* The shared `Broadcaster` token is resolved from the container.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ChannelConfig } from '../../notification_config.ts'
|
|
18
|
+
|
|
19
|
+
export interface BroadcastChannelConfig extends ChannelConfig {
|
|
20
|
+
driver: 'broadcast'
|
|
21
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `BroadcastNotificationDriver` — fans a notification onto the
|
|
3
|
+
* configured `Broadcaster`. Reads
|
|
4
|
+
* `notification.toBroadcast(notifiable): BroadcastNotificationPayload`
|
|
5
|
+
* for the channel routing + event body.
|
|
6
|
+
*
|
|
7
|
+
* Skips delivery (returns `{ delivered: false }` without throwing) when
|
|
8
|
+
* the hook is absent — same opt-out semantics as the mail / webhook
|
|
9
|
+
* drivers.
|
|
10
|
+
*
|
|
11
|
+
* Depends on `@strav/broadcast` (peer, optional on `@strav/notification`).
|
|
12
|
+
* Apps that want this driver register `BroadcastNotificationProvider`
|
|
13
|
+
* AND have a `BroadcastProvider` (or `PostgresBroadcastProvider`)
|
|
14
|
+
* binding `Broadcaster` in the container.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Broadcaster } from '@strav/broadcast'
|
|
18
|
+
import type { Notifiable } from '../../notifiable.ts'
|
|
19
|
+
import type { BaseNotification } from '../../notification.ts'
|
|
20
|
+
import type { NotificationDriver } from '../../notification_driver.ts'
|
|
21
|
+
import { NotificationDeliveryError } from '../../notification_error.ts'
|
|
22
|
+
import type { NotificationContext, NotificationDeliveryResult } from '../../types.ts'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What `toBroadcast(notifiable)` returns. `channel` is the target
|
|
26
|
+
* pub/sub channel name; `event` defaults to the notification class
|
|
27
|
+
* name when omitted; `data` is the JSON-serialisable payload.
|
|
28
|
+
*/
|
|
29
|
+
export interface BroadcastNotificationPayload {
|
|
30
|
+
channel: string
|
|
31
|
+
event?: string
|
|
32
|
+
data: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface BroadcastCapableNotification extends BaseNotification {
|
|
36
|
+
toBroadcast?(
|
|
37
|
+
notifiable: Notifiable,
|
|
38
|
+
): BroadcastNotificationPayload | Promise<BroadcastNotificationPayload>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BroadcastNotificationDriverOptions {
|
|
42
|
+
name: string
|
|
43
|
+
broadcaster: Broadcaster
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class BroadcastNotificationDriver implements NotificationDriver {
|
|
47
|
+
readonly name: string
|
|
48
|
+
private readonly broadcaster: Broadcaster
|
|
49
|
+
|
|
50
|
+
constructor(options: BroadcastNotificationDriverOptions) {
|
|
51
|
+
this.name = options.name
|
|
52
|
+
this.broadcaster = options.broadcaster
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async send(
|
|
56
|
+
notifiable: Notifiable,
|
|
57
|
+
notification: BaseNotification,
|
|
58
|
+
context: NotificationContext,
|
|
59
|
+
): Promise<NotificationDeliveryResult> {
|
|
60
|
+
const hook = (notification as BroadcastCapableNotification).toBroadcast
|
|
61
|
+
if (typeof hook !== 'function') {
|
|
62
|
+
return { channel: this.name, delivered: false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let payload: BroadcastNotificationPayload
|
|
66
|
+
try {
|
|
67
|
+
payload = await hook.call(notification, notifiable)
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
throw new NotificationDeliveryError(
|
|
70
|
+
`BroadcastNotificationDriver: toBroadcast() threw for channel "${this.name}".`,
|
|
71
|
+
{
|
|
72
|
+
context: {
|
|
73
|
+
channel: this.name,
|
|
74
|
+
notifiableId: notifiable.id,
|
|
75
|
+
notification: notification.constructor.name,
|
|
76
|
+
},
|
|
77
|
+
cause,
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await this.broadcaster.publish(payload.channel, {
|
|
84
|
+
id: context.id,
|
|
85
|
+
event: payload.event ?? notification.constructor.name,
|
|
86
|
+
data: payload.data,
|
|
87
|
+
})
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
throw new NotificationDeliveryError(
|
|
90
|
+
`BroadcastNotificationDriver: publish failed on broadcast channel "${payload.channel}".`,
|
|
91
|
+
{
|
|
92
|
+
context: {
|
|
93
|
+
channel: this.name,
|
|
94
|
+
broadcastChannel: payload.channel,
|
|
95
|
+
notifiableId: notifiable.id,
|
|
96
|
+
notification: notification.constructor.name,
|
|
97
|
+
},
|
|
98
|
+
cause,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { channel: this.name, delivered: true, reference: context.id }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Broadcaster } from '@strav/broadcast'
|
|
2
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
3
|
+
import { NotificationManager } from '../../notification_manager.ts'
|
|
4
|
+
import { BroadcastNotificationDriver } from './broadcast_notification_driver.ts'
|
|
5
|
+
|
|
6
|
+
export class BroadcastNotificationProvider extends ServiceProvider {
|
|
7
|
+
override readonly name = 'notification.broadcast'
|
|
8
|
+
override readonly dependencies = ['notification', 'broadcast']
|
|
9
|
+
|
|
10
|
+
override async boot(app: Application): Promise<void> {
|
|
11
|
+
const manager = app.resolve(NotificationManager)
|
|
12
|
+
const broadcaster = app.resolve(Broadcaster)
|
|
13
|
+
manager.extend(
|
|
14
|
+
'broadcast',
|
|
15
|
+
({ instanceName }) => new BroadcastNotificationDriver({ name: instanceName, broadcaster }),
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { BroadcastChannelConfig } from './broadcast_config.ts'
|
|
2
|
+
export {
|
|
3
|
+
BroadcastNotificationDriver,
|
|
4
|
+
type BroadcastNotificationDriverOptions,
|
|
5
|
+
type BroadcastNotificationPayload,
|
|
6
|
+
} from './broadcast_notification_driver.ts'
|
|
7
|
+
export { BroadcastNotificationProvider } from './broadcast_notification_provider.ts'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyNotificationMigration` — emit DDL for the `notification`
|
|
3
|
+
* table plus a `(notifiable_id, read_at)` lookup index used by
|
|
4
|
+
* `NotificationRepository.unread(...)`.
|
|
5
|
+
*
|
|
6
|
+
* Non-tenanted by default (framework policy: multitenancy is opt-in).
|
|
7
|
+
* Apps that need per-tenant scoping use
|
|
8
|
+
* `applyTenantedNotificationMigration` from
|
|
9
|
+
* `@strav/notification/tenanted` instead.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* export const migration: Migration = {
|
|
13
|
+
* name: '20260601000000_create_notification',
|
|
14
|
+
* async up(db) {
|
|
15
|
+
* await applyNotificationMigration(db, { registry })
|
|
16
|
+
* },
|
|
17
|
+
* async down(db) {
|
|
18
|
+
* await db.execute(emitDropTable(notificationSchema.name).sql)
|
|
19
|
+
* },
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { type DatabaseExecutor, emitCreateTable, type SchemaRegistry } from '@strav/database'
|
|
25
|
+
import { notificationSchema } from './schemas/notification_schema.ts'
|
|
26
|
+
|
|
27
|
+
export interface ApplyNotificationMigrationOptions {
|
|
28
|
+
registry: SchemaRegistry
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function applyNotificationMigration(
|
|
32
|
+
db: DatabaseExecutor,
|
|
33
|
+
options: ApplyNotificationMigrationOptions,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
const { registry } = options
|
|
36
|
+
await db.execute(emitCreateTable(notificationSchema, { registry }).sql)
|
|
37
|
+
// Badge / inbox lookup — "all unread for a recipient" is the
|
|
38
|
+
// hottest read path. Partial index keeps it tight.
|
|
39
|
+
await db.execute(
|
|
40
|
+
`CREATE INDEX IF NOT EXISTS "idx_notification_notifiable_unread"
|
|
41
|
+
ON "${notificationSchema.name}" ("notifiable_id", "created_at" DESC)
|
|
42
|
+
WHERE "read_at" IS NULL`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DatabaseNotificationDriver` — persists every dispatched notification
|
|
3
|
+
* into the `notification` ledger via `NotificationRepository.record`.
|
|
4
|
+
*
|
|
5
|
+
* Reads `notification.toDatabase(notifiable)` for the per-notification
|
|
6
|
+
* payload. When the hook is absent, the driver returns
|
|
7
|
+
* `{ delivered: false }` with no error — the channel chooses not to
|
|
8
|
+
* service notifications that don't model themselves as persistable.
|
|
9
|
+
*
|
|
10
|
+
* Tenanted variant uses `TenantedNotificationRepository` from
|
|
11
|
+
* `@strav/notification/tenanted` — the wiring is identical apart
|
|
12
|
+
* from the repository class.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Notifiable } from '../../notifiable.ts'
|
|
16
|
+
import type { BaseNotification } from '../../notification.ts'
|
|
17
|
+
import type { NotificationDriver } from '../../notification_driver.ts'
|
|
18
|
+
import { NotificationDeliveryError } from '../../notification_error.ts'
|
|
19
|
+
import type { NotificationContext, NotificationDeliveryResult } from '../../types.ts'
|
|
20
|
+
import type { NotificationRepository } from './notification_repository.ts'
|
|
21
|
+
|
|
22
|
+
interface PersistableNotification extends BaseNotification {
|
|
23
|
+
toDatabase?(notifiable: Notifiable): Record<string, unknown> | Promise<Record<string, unknown>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DatabaseNotificationDriverOptions {
|
|
27
|
+
name: string
|
|
28
|
+
repository: NotificationRepository
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class DatabaseNotificationDriver implements NotificationDriver {
|
|
32
|
+
readonly name: string
|
|
33
|
+
private readonly repository: NotificationRepository
|
|
34
|
+
|
|
35
|
+
constructor(options: DatabaseNotificationDriverOptions) {
|
|
36
|
+
this.name = options.name
|
|
37
|
+
this.repository = options.repository
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async send(
|
|
41
|
+
notifiable: Notifiable,
|
|
42
|
+
notification: BaseNotification,
|
|
43
|
+
context: NotificationContext,
|
|
44
|
+
): Promise<NotificationDeliveryResult> {
|
|
45
|
+
const hook = (notification as PersistableNotification).toDatabase
|
|
46
|
+
if (typeof hook !== 'function') {
|
|
47
|
+
return { channel: this.name, delivered: false }
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const data = await hook.call(notification, notifiable)
|
|
51
|
+
const record = await this.repository.record({
|
|
52
|
+
id: context.id,
|
|
53
|
+
notifiable,
|
|
54
|
+
type: notification.constructor.name,
|
|
55
|
+
data,
|
|
56
|
+
})
|
|
57
|
+
return { channel: this.name, delivered: true, reference: record.id }
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
throw new NotificationDeliveryError(
|
|
60
|
+
`DatabaseNotificationDriver: persist failed for channel "${this.name}".`,
|
|
61
|
+
{
|
|
62
|
+
context: {
|
|
63
|
+
channel: this.name,
|
|
64
|
+
notifiableId: notifiable.id,
|
|
65
|
+
notification: notification.constructor.name,
|
|
66
|
+
},
|
|
67
|
+
cause,
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceProvider that registers the database-channel factory on the
|
|
3
|
+
* `NotificationManager` and the `NotificationRepository` as a
|
|
4
|
+
* container singleton.
|
|
5
|
+
*
|
|
6
|
+
* Apps include this AFTER `NotificationProvider` + `DatabaseProvider`.
|
|
7
|
+
* The factory reads each channel's `tenanted` flag at construction
|
|
8
|
+
* time — but the tenanted-repository wiring is the app's job
|
|
9
|
+
* (apps that want it import from `@strav/notification/tenanted` and
|
|
10
|
+
* register the tenanted-repository binding themselves, same pattern
|
|
11
|
+
* as `@strav/social/tenanted`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { PostgresDatabase, SchemaRegistry } from '@strav/database'
|
|
15
|
+
import { type Application, EventBus, ServiceProvider } from '@strav/kernel'
|
|
16
|
+
import { NotificationManager } from '../../notification_manager.ts'
|
|
17
|
+
import { DatabaseNotificationDriver } from './database_notification_driver.ts'
|
|
18
|
+
import { NotificationRepository } from './notification_repository.ts'
|
|
19
|
+
|
|
20
|
+
export class DatabaseNotificationProvider extends ServiceProvider {
|
|
21
|
+
override readonly name = 'notification.database'
|
|
22
|
+
override readonly dependencies = ['notification', 'database']
|
|
23
|
+
|
|
24
|
+
override register(app: Application): void {
|
|
25
|
+
app.singleton(
|
|
26
|
+
NotificationRepository,
|
|
27
|
+
(c) =>
|
|
28
|
+
new NotificationRepository({
|
|
29
|
+
db: c.resolve(PostgresDatabase),
|
|
30
|
+
events: c.resolve(EventBus),
|
|
31
|
+
registry: c.resolve(SchemaRegistry),
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override async boot(app: Application): Promise<void> {
|
|
37
|
+
const manager = app.resolve(NotificationManager)
|
|
38
|
+
const repository = app.resolve(NotificationRepository)
|
|
39
|
+
manager.extend(
|
|
40
|
+
'database',
|
|
41
|
+
({ instanceName }) => new DatabaseNotificationDriver({ name: instanceName, repository }),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ApplyNotificationMigrationOptions,
|
|
3
|
+
applyNotificationMigration,
|
|
4
|
+
} from './apply_notification_migration.ts'
|
|
5
|
+
export type { DatabaseChannelConfig } from './database_config.ts'
|
|
6
|
+
export {
|
|
7
|
+
DatabaseNotificationDriver,
|
|
8
|
+
type DatabaseNotificationDriverOptions,
|
|
9
|
+
} from './database_notification_driver.ts'
|
|
10
|
+
export { DatabaseNotificationProvider } from './database_notification_provider.ts'
|
|
11
|
+
export { NotificationRecord } from './notification_record.ts'
|
|
12
|
+
export {
|
|
13
|
+
NotificationRepository,
|
|
14
|
+
type RecordInput,
|
|
15
|
+
} from './notification_repository.ts'
|
|
16
|
+
export { notificationSchema } from './schemas/notification_schema.ts'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NotificationRecord` — typed row of the `notification` ledger.
|
|
3
|
+
* Apps subclass when they want a per-notifiable model (e.g. extend
|
|
4
|
+
* with `notifiable_relation` helpers); the framework only needs the
|
|
5
|
+
* generic shape.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Model } from '@strav/database'
|
|
9
|
+
import { notificationSchema } from './schemas/notification_schema.ts'
|
|
10
|
+
|
|
11
|
+
export class NotificationRecord extends Model {
|
|
12
|
+
static override readonly schema = notificationSchema
|
|
13
|
+
|
|
14
|
+
id!: string
|
|
15
|
+
notifiable_id!: string
|
|
16
|
+
notifiable_type!: string
|
|
17
|
+
type!: string
|
|
18
|
+
data!: Record<string, unknown>
|
|
19
|
+
read_at!: Date | null
|
|
20
|
+
created_at!: Date
|
|
21
|
+
updated_at!: Date
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NotificationRepository` — domain helpers on top of the generic
|
|
3
|
+
* `Repository<NotificationRecord>` surface. The methods apps actually
|
|
4
|
+
* reach for:
|
|
5
|
+
*
|
|
6
|
+
* - `record({ id, notifiable, type, data })` — insert a fresh row.
|
|
7
|
+
* Called by `DatabaseNotificationDriver.send()`; apps usually
|
|
8
|
+
* don't invoke this directly.
|
|
9
|
+
*
|
|
10
|
+
* - `unread(notifiable)` — every unread row for one recipient,
|
|
11
|
+
* newest first. Apps render a badge from `unread(...).length`.
|
|
12
|
+
*
|
|
13
|
+
* - `markAsRead(id)` — flip `read_at` from null to now. Returns
|
|
14
|
+
* the updated row.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { quoteIdent, Repository } from '@strav/database'
|
|
18
|
+
import type { Notifiable } from '../../notifiable.ts'
|
|
19
|
+
import { NotificationRecord } from './notification_record.ts'
|
|
20
|
+
import { notificationSchema } from './schemas/notification_schema.ts'
|
|
21
|
+
|
|
22
|
+
export interface RecordInput {
|
|
23
|
+
/** Notification ULID (matches `NotificationContext.id`). */
|
|
24
|
+
id: string
|
|
25
|
+
notifiable: Notifiable
|
|
26
|
+
/** Notification class name (`notification.constructor.name`). */
|
|
27
|
+
type: string
|
|
28
|
+
/** jsonb payload from `notification.toDatabase(notifiable)`. */
|
|
29
|
+
data: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class NotificationRepository extends Repository<NotificationRecord> {
|
|
33
|
+
static override readonly schema = notificationSchema
|
|
34
|
+
static override readonly model = NotificationRecord
|
|
35
|
+
|
|
36
|
+
async record(input: RecordInput): Promise<NotificationRecord> {
|
|
37
|
+
const now = new Date()
|
|
38
|
+
return this.create({
|
|
39
|
+
id: input.id,
|
|
40
|
+
notifiable_id: String(input.notifiable.id),
|
|
41
|
+
notifiable_type: input.notifiable.notifiableType ?? 'Notifiable',
|
|
42
|
+
type: input.type,
|
|
43
|
+
data: input.data,
|
|
44
|
+
read_at: null,
|
|
45
|
+
created_at: now,
|
|
46
|
+
updated_at: now,
|
|
47
|
+
} as Partial<NotificationRecord>)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async unread(notifiable: Notifiable): Promise<NotificationRecord[]> {
|
|
51
|
+
const table = quoteIdent(notificationSchema.name)
|
|
52
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
53
|
+
`SELECT * FROM ${table}
|
|
54
|
+
WHERE "notifiable_id" = $1 AND "read_at" IS NULL
|
|
55
|
+
ORDER BY "created_at" DESC`,
|
|
56
|
+
[String(notifiable.id)],
|
|
57
|
+
)
|
|
58
|
+
return rows.map((r) => this.hydrate(r))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async markAsRead(id: string): Promise<NotificationRecord | undefined> {
|
|
62
|
+
const found = await this.findMany([id])
|
|
63
|
+
const model = found[0]
|
|
64
|
+
if (!model) return undefined
|
|
65
|
+
return this.update(model, { read_at: new Date() } as Partial<NotificationRecord>)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notificationSchema` — append-only ledger of dispatched
|
|
3
|
+
* notifications. **Non-tenanted by default** (framework policy:
|
|
4
|
+
* multitenancy is opt-in). Apps that need per-tenant scoping import
|
|
5
|
+
* `tenantedNotificationSchema` from `@strav/notification/tenanted`
|
|
6
|
+
* instead.
|
|
7
|
+
*
|
|
8
|
+
* One row per `(notifiable, notification, dispatch)` triple. The
|
|
9
|
+
* `read_at` column lets apps render an "unread" badge by counting
|
|
10
|
+
* rows where it's null.
|
|
11
|
+
*
|
|
12
|
+
* Columns:
|
|
13
|
+
*
|
|
14
|
+
* - `id` ULID PK — matches `NotificationContext.id`
|
|
15
|
+
* so a notification's persisted row, log line,
|
|
16
|
+
* and any downstream channel references all
|
|
17
|
+
* share the same correlation id.
|
|
18
|
+
* - `notifiable_id` Recipient's domain id (string). Apps store
|
|
19
|
+
* ulids, uuids, ints — all fit as strings.
|
|
20
|
+
* - `notifiable_type` Recipient class name (free-form). Apps
|
|
21
|
+
* with one Notifiable model (e.g. `User`)
|
|
22
|
+
* leave it constant; multi-Notifiable apps
|
|
23
|
+
* use it to dispatch resolution.
|
|
24
|
+
* - `type` Notification class name. Apps render by
|
|
25
|
+
* type (`new_message`, `invoice_paid`, …).
|
|
26
|
+
* - `data` jsonb payload from `toDatabase(notifiable)`.
|
|
27
|
+
* - `read_at` When the recipient marked this read.
|
|
28
|
+
* Null = unread.
|
|
29
|
+
* - `created_at` Dispatch timestamp.
|
|
30
|
+
* - `updated_at` Last touched (mark-as-read).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
34
|
+
|
|
35
|
+
export const notificationSchema = defineSchema('notification', Archetype.Entity, (t) => {
|
|
36
|
+
t.id()
|
|
37
|
+
t.string('notifiable_id').max(64).notNull()
|
|
38
|
+
t.string('notifiable_type').max(128).notNull()
|
|
39
|
+
t.string('type').max(128).notNull()
|
|
40
|
+
t.json('data').notNull().default({})
|
|
41
|
+
t.timestamp('read_at').nullable()
|
|
42
|
+
t.timestamp('created_at').notNull()
|
|
43
|
+
t.timestamp('updated_at').notNull()
|
|
44
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type DatabaseExecutor, emitCreateTable, type SchemaRegistry } from '@strav/database'
|
|
2
|
+
import { tenantedNotificationSchema } from './schemas/tenanted_notification_schema.ts'
|
|
3
|
+
|
|
4
|
+
export interface ApplyTenantedNotificationMigrationOptions {
|
|
5
|
+
registry: SchemaRegistry
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function applyTenantedNotificationMigration(
|
|
9
|
+
db: DatabaseExecutor,
|
|
10
|
+
options: ApplyTenantedNotificationMigrationOptions,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const { registry } = options
|
|
13
|
+
await db.execute(emitCreateTable(tenantedNotificationSchema, { registry }).sql)
|
|
14
|
+
await db.execute(
|
|
15
|
+
`CREATE INDEX IF NOT EXISTS "idx_notification_notifiable_unread"
|
|
16
|
+
ON "${tenantedNotificationSchema.name}" ("tenant_id", "notifiable_id", "created_at" DESC)
|
|
17
|
+
WHERE "read_at" IS NULL`,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ApplyTenantedNotificationMigrationOptions,
|
|
3
|
+
applyTenantedNotificationMigration,
|
|
4
|
+
} from './apply_tenanted_notification_migration.ts'
|
|
5
|
+
export { tenantedNotificationSchema } from './schemas/tenanted_notification_schema.ts'
|
|
6
|
+
export { TenantedNotificationRecord } from './tenanted_notification_record.ts'
|
|
7
|
+
export {
|
|
8
|
+
type RecordInput as TenantedRecordInput,
|
|
9
|
+
TenantedNotificationRepository,
|
|
10
|
+
} from './tenanted_notification_repository.ts'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tenantedNotificationSchema` — opt-in tenant-scoped variant of the
|
|
3
|
+
* notification ledger. Same columns as the default schema, with
|
|
4
|
+
* `tenanted: true` so `@strav/database` injects the `tenant_id` FK
|
|
5
|
+
* + RLS policy.
|
|
6
|
+
*
|
|
7
|
+
* Apps register this schema instead of the default. The matching
|
|
8
|
+
* `Model` + `Repository` + `applyTenantedNotificationMigration` ship
|
|
9
|
+
* here too so the wiring stays consistent.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
13
|
+
|
|
14
|
+
export const tenantedNotificationSchema = defineSchema(
|
|
15
|
+
'notification',
|
|
16
|
+
Archetype.Entity,
|
|
17
|
+
(t) => {
|
|
18
|
+
t.id()
|
|
19
|
+
t.string('notifiable_id').max(64).notNull()
|
|
20
|
+
t.string('notifiable_type').max(128).notNull()
|
|
21
|
+
t.string('type').max(128).notNull()
|
|
22
|
+
t.json('data').notNull().default({})
|
|
23
|
+
t.timestamp('read_at').nullable()
|
|
24
|
+
t.timestamp('created_at').notNull()
|
|
25
|
+
t.timestamp('updated_at').notNull()
|
|
26
|
+
},
|
|
27
|
+
{ tenanted: true },
|
|
28
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Model } from '@strav/database'
|
|
2
|
+
import { tenantedNotificationSchema } from './schemas/tenanted_notification_schema.ts'
|
|
3
|
+
|
|
4
|
+
export class TenantedNotificationRecord extends Model {
|
|
5
|
+
static override readonly schema = tenantedNotificationSchema
|
|
6
|
+
|
|
7
|
+
id!: string
|
|
8
|
+
notifiable_id!: string
|
|
9
|
+
notifiable_type!: string
|
|
10
|
+
type!: string
|
|
11
|
+
data!: Record<string, unknown>
|
|
12
|
+
read_at!: Date | null
|
|
13
|
+
created_at!: Date
|
|
14
|
+
updated_at!: Date
|
|
15
|
+
}
|