@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/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,7 @@
1
+ import type { ChannelConfig } from '../../notification_config.ts'
2
+
3
+ export interface DatabaseChannelConfig extends ChannelConfig {
4
+ driver: 'database'
5
+ /** Force tenanted variant. Default: non-tenanted (framework policy: opt-in). */
6
+ tenanted?: boolean
7
+ }
@@ -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
+ }