@warlock.js/notifications 5.24.0 → 5.26.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/CHANGELOG.md +12 -0
- package/cjs/index.cjs +1 -1
- package/cjs/rabbitmq-driver-DvWaCPES.cjs +3 -0
- package/cjs/{src-DbAWktJm.cjs → src-BVlqtCR2.cjs} +22 -6
- package/cjs/src-BVlqtCR2.cjs.map +1 -0
- package/esm/herald/src/communicators/broker-registry.mjs.map +1 -1
- package/esm/herald/src/communicators/broker.mjs.map +1 -1
- package/esm/herald/src/drivers/rabbitmq/rabbitmq-channel.mjs.map +1 -1
- package/esm/herald/src/drivers/rabbitmq/rabbitmq-driver.mjs.map +1 -1
- package/esm/herald/src/message-managers/prepare-consumer-subscription.mjs.map +1 -1
- package/esm/herald/src/utils/connect-to-broker.mjs.map +1 -1
- package/esm/seal/src/config.mjs.map +1 -1
- package/esm/seal/src/factory/validate.mjs.map +1 -1
- package/esm/seal/src/factory/validators.mjs +1 -1
- package/esm/seal/src/factory/validators.mjs.map +1 -1
- package/esm/seal/src/helpers/date-helpers.mjs.map +1 -1
- package/esm/seal/src/helpers/get-field-value.mjs.map +1 -1
- package/esm/seal/src/helpers/path-helpers.mjs.map +1 -1
- package/esm/seal/src/mutators/array-mutators.mjs.map +1 -1
- package/esm/seal/src/mutators/date-mutators.mjs.map +1 -1
- package/esm/seal/src/mutators/number-mutators.mjs.map +1 -1
- package/esm/seal/src/mutators/object-mutators.mjs.map +1 -1
- package/esm/seal/src/mutators/scalar-mutators.mjs.map +1 -1
- package/esm/seal/src/mutators/string-mutators.mjs.map +1 -1
- package/esm/seal/src/rules/array/array-rules.mjs.map +1 -1
- package/esm/seal/src/rules/color/color-rules.mjs.map +1 -1
- package/esm/seal/src/rules/common/enum.mjs.map +1 -1
- package/esm/seal/src/rules/common/equals-field-rules.mjs.map +1 -1
- package/esm/seal/src/rules/common/type-rules.mjs.map +1 -1
- package/esm/seal/src/rules/conditional/forbidden-if-rules.mjs.map +1 -1
- package/esm/seal/src/rules/conditional/present-if-rules.mjs.map +1 -1
- package/esm/seal/src/rules/conditional/present-unless-rules.mjs.map +1 -1
- package/esm/seal/src/rules/conditional/present-with-rules.mjs.map +1 -1
- package/esm/seal/src/rules/conditional/present-without-rules.mjs.map +1 -1
- package/esm/seal/src/rules/conditional/required-if-rules.mjs.map +1 -1
- package/esm/seal/src/rules/conditional/required-unless-rules.mjs.map +1 -1
- package/esm/seal/src/rules/conditional/required-with-rules.mjs.map +1 -1
- package/esm/seal/src/rules/conditional/required-without-rules.mjs.map +1 -1
- package/esm/seal/src/rules/core/equal.mjs.map +1 -1
- package/esm/seal/src/rules/core/forbidden.mjs.map +1 -1
- package/esm/seal/src/rules/core/required.mjs.map +1 -1
- package/esm/seal/src/rules/core/union.mjs.map +1 -1
- package/esm/seal/src/rules/core/when.mjs.map +1 -1
- package/esm/seal/src/rules/date/date-comparison-rules.mjs.map +1 -1
- package/esm/seal/src/rules/date/date-day-rules.mjs.map +1 -1
- package/esm/seal/src/rules/date/date-field-comparison-rules.mjs.map +1 -1
- package/esm/seal/src/rules/date/date-period-rules.mjs.map +1 -1
- package/esm/seal/src/rules/date/date-relative-rules.mjs.map +1 -1
- package/esm/seal/src/rules/date/date-special-rules.mjs.map +1 -1
- package/esm/seal/src/rules/date/date.mjs.map +1 -1
- package/esm/seal/src/rules/length/length-rules.mjs.map +1 -1
- package/esm/seal/src/rules/number/number-rules.mjs.map +1 -1
- package/esm/seal/src/rules/scalar/accepted-rule.mjs.map +1 -1
- package/esm/seal/src/rules/scalar/declined-rule.mjs.map +1 -1
- package/esm/seal/src/rules/string/alpha.mjs.map +1 -1
- package/esm/seal/src/rules/string/credit-card.mjs.map +1 -1
- package/esm/seal/src/rules/string/email.mjs.map +1 -1
- package/esm/seal/src/rules/string/pattern.mjs.map +1 -1
- package/esm/seal/src/rules/string/string-comparison.mjs.map +1 -1
- package/esm/seal/src/rules/string/strong-password-rule.mjs.map +1 -1
- package/esm/seal/src/rules/string/url.mjs.map +1 -1
- package/esm/seal/src/rules/string/without-whitespace.mjs.map +1 -1
- package/esm/seal/src/types/date-types.mjs.map +1 -1
- package/esm/seal/src/validators/any-validator.mjs.map +1 -1
- package/esm/seal/src/validators/array-validator.mjs +1 -0
- package/esm/seal/src/validators/array-validator.mjs.map +1 -1
- package/esm/seal/src/validators/base-validator.mjs +12 -1
- package/esm/seal/src/validators/base-validator.mjs.map +1 -1
- package/esm/seal/src/validators/boolean-validator.mjs +1 -0
- package/esm/seal/src/validators/boolean-validator.mjs.map +1 -1
- package/esm/seal/src/validators/date-validator.mjs +1 -0
- package/esm/seal/src/validators/date-validator.mjs.map +1 -1
- package/esm/seal/src/validators/float-validator.mjs.map +1 -1
- package/esm/seal/src/validators/int-validator.mjs.map +1 -1
- package/esm/seal/src/validators/literal-validator.mjs +1 -3
- package/esm/seal/src/validators/literal-validator.mjs.map +1 -1
- package/esm/seal/src/validators/methods/equality-conditional-methods.mjs.map +1 -1
- package/esm/seal/src/validators/methods/forbidden-methods.mjs.map +1 -1
- package/esm/seal/src/validators/methods/present-methods.mjs.map +1 -1
- package/esm/seal/src/validators/methods/required-methods.mjs.map +1 -1
- package/esm/seal/src/validators/number-validator.mjs +1 -0
- package/esm/seal/src/validators/number-validator.mjs.map +1 -1
- package/esm/seal/src/validators/numeric-validator.mjs.map +1 -1
- package/esm/seal/src/validators/object-validator.mjs +1 -1
- package/esm/seal/src/validators/object-validator.mjs.map +1 -1
- package/esm/seal/src/validators/record-validator.mjs.map +1 -1
- package/esm/seal/src/validators/scalar-validator.mjs +1 -0
- package/esm/seal/src/validators/scalar-validator.mjs.map +1 -1
- package/esm/seal/src/validators/string-validator.mjs.map +1 -1
- package/esm/seal/src/validators/tuple-validator.mjs.map +1 -1
- package/esm/seal/src/validators/union-validator.mjs.map +1 -1
- package/llms-full.txt +901 -891
- package/package.json +7 -7
- package/skills/configure-notifications/SKILL.md +138 -138
- package/skills/define-channel/SKILL.md +107 -107
- package/skills/define-notification/SKILL.md +133 -133
- package/skills/observe-notifications/SKILL.md +66 -66
- package/skills/queue-notifications/SKILL.md +110 -110
- package/skills/send-ad-hoc/SKILL.md +95 -95
- package/skills/use-in-app/SKILL.md +172 -172
- package/skills/write-notification-migration/SKILL.md +66 -66
- package/cjs/rabbitmq-driver-C0o3Pz15.cjs +0 -3
- package/cjs/src-DbAWktJm.cjs.map +0 -1
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: send-ad-hoc
|
|
3
|
-
description: 'Ad-hoc per-channel send via the `notify.<channel>(to, payload, options?)` Proxy facade. Works for any channel registered in `NotificationChannels` (built-in or custom). `to` accepts a `Notifiable` model OR a raw route string (`"x@y.com"`); `payload` is typed per channel via the registry; `options` is the standard `SendOptions`. `notify.channel(name).send(...)` is the runtime escape when the channel name is not a compile-time literal. Type-based preference gating applies when a type is known — explicit via `options.type` OR auto-detected from a database payload''s `type` field. For multi-channel sends use `defineNotification` instead — there is no inline `notify(to, { via, ... })` form on purpose. Triggers: `notify.<channel>`, `notify.channel(name)`, `notify.mail`, `notify.database`, "send a one-off notification", "ad-hoc notification", "send an email without defining a notification", "raw email target", "dynamic channel name"; typical import `import { notify } from "@warlock.js/notifications"`. Skip: reusable multi-channel notifications — `@warlock.js/notifications/define-notification/SKILL.md`; configuring channels — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# `notify.<channel>` — ad-hoc, per-channel sends
|
|
7
|
-
|
|
8
|
-
When you don't need a reusable definition — fire one channel, one recipient.
|
|
9
|
-
|
|
10
|
-
```ts
|
|
11
|
-
import { notify } from "@warlock.js/notifications";
|
|
12
|
-
|
|
13
|
-
await notify.mail(user, { subject: "Welcome", html: "<p>Hi!</p>" });
|
|
14
|
-
await notify.database(user, { type: "welcome", title: "Welcome!" });
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
For **multi-channel** sends, use [`defineNotification`](../define-notification/SKILL.md) — that's the reusable pattern and the only mental model for "send through several channels at once" (no inline multi-channel overload).
|
|
18
|
-
|
|
19
|
-
## Shape
|
|
20
|
-
|
|
21
|
-
```ts
|
|
22
|
-
notify.<channel>(
|
|
23
|
-
to: Notifiable | string, // model OR raw route
|
|
24
|
-
payload: NotificationChannels[channel], // typed per channel
|
|
25
|
-
options?: SendOptions,
|
|
26
|
-
): Promise<void>
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
## Raw target — no model
|
|
30
|
-
|
|
31
|
-
Pass a string instead of a model to use the string as the route directly:
|
|
32
|
-
|
|
33
|
-
```ts
|
|
34
|
-
await notify.mail("guest@example.com", { subject: "Receipt", html: "<p>…</p>" });
|
|
35
|
-
await notify.whatsapp("+201234567890", { body: "Promo: 20% off!" }); // Phase 2
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
When the recipient isn't a model, `PreferenceProvider` and `RateLimiter` are skipped — there's no `Notifiable` to consult.
|
|
39
|
-
|
|
40
|
-
## Runtime-dynamic channel name
|
|
41
|
-
|
|
42
|
-
When the channel isn't a literal, use `notify.channel(name).send(...)`:
|
|
43
|
-
|
|
44
|
-
```ts
|
|
45
|
-
const channelName = await chooseChannelForUser(user);
|
|
46
|
-
await notify.channel(channelName).send(user, payload);
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
This is the only path with `unknown`-typed payloads — prefer the literal `notify.<channel>` when you can. `to` is still what decides the recipient: even for the `database` channel, a `recipientId`-shaped key on `payload` (or the model's physical recipient column name) is stripped before the row is built, so an untyped payload assembled from request JSON can't redirect the write to another recipient — see [`use-in-app/SKILL.md`](../use-in-app/SKILL.md#createfor-write-side--no-idor-either-since-4150).
|
|
50
|
-
|
|
51
|
-
## Opt into preference/rate-limit gating
|
|
52
|
-
|
|
53
|
-
Ad-hoc sends bypass the preference gate unless a notification `type` is known. Two ways to provide it:
|
|
54
|
-
|
|
55
|
-
```ts
|
|
56
|
-
// 1. Explicit — wins regardless of payload.
|
|
57
|
-
await notify.mail(user, payload, { type: "marketing.weekly" });
|
|
58
|
-
|
|
59
|
-
// 2. Auto-detected — database payloads carry their own `type` field.
|
|
60
|
-
await notify.database(user, { type: "welcome", title: "Welcome!" });
|
|
61
|
-
// ^^^^^^^^^^^^^^^ used for gating
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
`SendOptions.force === true` bypasses preferences. `RateLimiter` is consulted whenever a type is known (regardless of `force`).
|
|
65
|
-
|
|
66
|
-
## Custom channels
|
|
67
|
-
|
|
68
|
-
After registering a custom channel (see [`define-channel`](../define-channel/SKILL.md)), it's first-class:
|
|
69
|
-
|
|
70
|
-
```ts
|
|
71
|
-
await notify.discord(user, { content: "Build finished 🎉" });
|
|
72
|
-
await notify.slack(staff, { text: "Deploy starting" });
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
## SendOptions (3rd arg)
|
|
76
|
-
|
|
77
|
-
| Field | Effect |
|
|
78
|
-
|---|---|
|
|
79
|
-
| `delay` | Reserved for `.queue` paths; not used by `notify.<channel>` (sync). |
|
|
80
|
-
| `locale` | Forwarded to the channel's `send({ options })`. |
|
|
81
|
-
| `meta` | Forwarded to the channel + included in every observability event. |
|
|
82
|
-
| `idempotencyKey` | Injected into the database payload (dedupe). |
|
|
83
|
-
| `force` | Bypass `PreferenceProvider`. Does NOT bypass `RateLimiter`. |
|
|
84
|
-
| `type` | Notification type for gating (ad-hoc only). |
|
|
85
|
-
|
|
86
|
-
## Failure surfaces
|
|
87
|
-
|
|
88
|
-
`notify.<channel>` rejects on configuration errors (`ChannelNotFoundError`) — those are loud, immediate, and don't fire a `failed` event. Channel-level send failures rethrow AND fire a `failed` event with the channel + error.
|
|
89
|
-
|
|
90
|
-
## See also
|
|
91
|
-
|
|
92
|
-
- [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — front door + mental model.
|
|
93
|
-
- [`define-notification/SKILL.md`](../define-notification/SKILL.md) — reusable multi-channel.
|
|
94
|
-
- [`define-channel/SKILL.md`](../define-channel/SKILL.md) — register a custom channel.
|
|
95
|
-
- [`observe-notifications/SKILL.md`](../observe-notifications/SKILL.md) — `sending` / `sent` / `failed` / `skipped` events.
|
|
1
|
+
---
|
|
2
|
+
name: send-ad-hoc
|
|
3
|
+
description: 'Ad-hoc per-channel send via the `notify.<channel>(to, payload, options?)` Proxy facade. Works for any channel registered in `NotificationChannels` (built-in or custom). `to` accepts a `Notifiable` model OR a raw route string (`"x@y.com"`); `payload` is typed per channel via the registry; `options` is the standard `SendOptions`. `notify.channel(name).send(...)` is the runtime escape when the channel name is not a compile-time literal. Type-based preference gating applies when a type is known — explicit via `options.type` OR auto-detected from a database payload''s `type` field. For multi-channel sends use `defineNotification` instead — there is no inline `notify(to, { via, ... })` form on purpose. Triggers: `notify.<channel>`, `notify.channel(name)`, `notify.mail`, `notify.database`, "send a one-off notification", "ad-hoc notification", "send an email without defining a notification", "raw email target", "dynamic channel name"; typical import `import { notify } from "@warlock.js/notifications"`. Skip: reusable multi-channel notifications — `@warlock.js/notifications/define-notification/SKILL.md`; configuring channels — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `notify.<channel>` — ad-hoc, per-channel sends
|
|
7
|
+
|
|
8
|
+
When you don't need a reusable definition — fire one channel, one recipient.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { notify } from "@warlock.js/notifications";
|
|
12
|
+
|
|
13
|
+
await notify.mail(user, { subject: "Welcome", html: "<p>Hi!</p>" });
|
|
14
|
+
await notify.database(user, { type: "welcome", title: "Welcome!" });
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For **multi-channel** sends, use [`defineNotification`](../define-notification/SKILL.md) — that's the reusable pattern and the only mental model for "send through several channels at once" (no inline multi-channel overload).
|
|
18
|
+
|
|
19
|
+
## Shape
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
notify.<channel>(
|
|
23
|
+
to: Notifiable | string, // model OR raw route
|
|
24
|
+
payload: NotificationChannels[channel], // typed per channel
|
|
25
|
+
options?: SendOptions,
|
|
26
|
+
): Promise<void>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Raw target — no model
|
|
30
|
+
|
|
31
|
+
Pass a string instead of a model to use the string as the route directly:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
await notify.mail("guest@example.com", { subject: "Receipt", html: "<p>…</p>" });
|
|
35
|
+
await notify.whatsapp("+201234567890", { body: "Promo: 20% off!" }); // Phase 2
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
When the recipient isn't a model, `PreferenceProvider` and `RateLimiter` are skipped — there's no `Notifiable` to consult.
|
|
39
|
+
|
|
40
|
+
## Runtime-dynamic channel name
|
|
41
|
+
|
|
42
|
+
When the channel isn't a literal, use `notify.channel(name).send(...)`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const channelName = await chooseChannelForUser(user);
|
|
46
|
+
await notify.channel(channelName).send(user, payload);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This is the only path with `unknown`-typed payloads — prefer the literal `notify.<channel>` when you can. `to` is still what decides the recipient: even for the `database` channel, a `recipientId`-shaped key on `payload` (or the model's physical recipient column name) is stripped before the row is built, so an untyped payload assembled from request JSON can't redirect the write to another recipient — see [`use-in-app/SKILL.md`](../use-in-app/SKILL.md#createfor-write-side--no-idor-either-since-4150).
|
|
50
|
+
|
|
51
|
+
## Opt into preference/rate-limit gating
|
|
52
|
+
|
|
53
|
+
Ad-hoc sends bypass the preference gate unless a notification `type` is known. Two ways to provide it:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// 1. Explicit — wins regardless of payload.
|
|
57
|
+
await notify.mail(user, payload, { type: "marketing.weekly" });
|
|
58
|
+
|
|
59
|
+
// 2. Auto-detected — database payloads carry their own `type` field.
|
|
60
|
+
await notify.database(user, { type: "welcome", title: "Welcome!" });
|
|
61
|
+
// ^^^^^^^^^^^^^^^ used for gating
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`SendOptions.force === true` bypasses preferences. `RateLimiter` is consulted whenever a type is known (regardless of `force`).
|
|
65
|
+
|
|
66
|
+
## Custom channels
|
|
67
|
+
|
|
68
|
+
After registering a custom channel (see [`define-channel`](../define-channel/SKILL.md)), it's first-class:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
await notify.discord(user, { content: "Build finished 🎉" });
|
|
72
|
+
await notify.slack(staff, { text: "Deploy starting" });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## SendOptions (3rd arg)
|
|
76
|
+
|
|
77
|
+
| Field | Effect |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `delay` | Reserved for `.queue` paths; not used by `notify.<channel>` (sync). |
|
|
80
|
+
| `locale` | Forwarded to the channel's `send({ options })`. |
|
|
81
|
+
| `meta` | Forwarded to the channel + included in every observability event. |
|
|
82
|
+
| `idempotencyKey` | Injected into the database payload (dedupe). |
|
|
83
|
+
| `force` | Bypass `PreferenceProvider`. Does NOT bypass `RateLimiter`. |
|
|
84
|
+
| `type` | Notification type for gating (ad-hoc only). |
|
|
85
|
+
|
|
86
|
+
## Failure surfaces
|
|
87
|
+
|
|
88
|
+
`notify.<channel>` rejects on configuration errors (`ChannelNotFoundError`) — those are loud, immediate, and don't fire a `failed` event. Channel-level send failures rethrow AND fire a `failed` event with the channel + error.
|
|
89
|
+
|
|
90
|
+
## See also
|
|
91
|
+
|
|
92
|
+
- [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — front door + mental model.
|
|
93
|
+
- [`define-notification/SKILL.md`](../define-notification/SKILL.md) — reusable multi-channel.
|
|
94
|
+
- [`define-channel/SKILL.md`](../define-channel/SKILL.md) — register a custom channel.
|
|
95
|
+
- [`observe-notifications/SKILL.md`](../observe-notifications/SKILL.md) — `sending` / `sent` / `failed` / `skipped` events.
|
|
@@ -1,172 +1,172 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: use-in-app
|
|
3
|
-
description: 'Use the `inApp` facade for the in-app/database channel''s READ side: `inApp.configure({ model: Notification })` or `{ repository: myRepo }` binds the store AND returns the channel; `inApp.list / listUnread / countUnread / markAsRead / markAsUnread` are RECIPIENT-SCOPED by construction (no IDOR — `markAsRead(user, id)` cannot flip another user''s row even with a foreign id). `countUnread` uses `RepositoryManager.countCached` for the badge fast-path. Triggers: `inApp.configure`, `inApp.list`, `inApp.listUnread`, `inApp.countUnread`, `inApp.markAsRead`, `inApp.markAsUnread`, `BaseNotificationsRepository`, `DatabaseNotification`, `NotificationsFilter`, "in-app notifications", "mark notification as read", "unread count", "notification badge", "IDOR-safe markAsRead"; typical import `import { inApp } from "@warlock.js/notifications"`. Skip: writing notifications (use `defineNotification` or `notify.database`) — `@warlock.js/notifications/define-notification/SKILL.md`; the migration columns — `@warlock.js/notifications/write-notification-migration/SKILL.md`; wiring it into config — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# `inApp` — in-app notification read API
|
|
7
|
-
|
|
8
|
-
The in-app database channel has two sides:
|
|
9
|
-
|
|
10
|
-
- **Write side** — `notify.database(user, {...})` and the database channel inside `defineNotification` create rows.
|
|
11
|
-
- **Read side** — `inApp.list` / `listUnread` / `countUnread` / `markAsRead` / `markAsUnread` query and mutate.
|
|
12
|
-
|
|
13
|
-
`inApp.configure({ model | repository })` binds the same repository for both sides, so cache invalidation just works.
|
|
14
|
-
|
|
15
|
-
## Configure (once, in `config/notifications.ts`)
|
|
16
|
-
|
|
17
|
-
```ts
|
|
18
|
-
// 90% case — pass the model class; default repo built internally.
|
|
19
|
-
database: inApp.configure({ model: Notification }),
|
|
20
|
-
|
|
21
|
-
// 10% case — pass a custom repo.
|
|
22
|
-
database: inApp.configure({ repository: notificationsRepository }),
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
## Reads
|
|
26
|
-
|
|
27
|
-
```ts
|
|
28
|
-
import { inApp } from "@warlock.js/notifications";
|
|
29
|
-
|
|
30
|
-
// all rows for this recipient (paginated via the repo's standard `list`)
|
|
31
|
-
const all = await inApp.list(user);
|
|
32
|
-
|
|
33
|
-
// unread-only — pass `filter` to add type/etc. constraints
|
|
34
|
-
const unread = await inApp.listUnread(user, { type: "order.shipped" });
|
|
35
|
-
|
|
36
|
-
// cached unread count — backs the badge
|
|
37
|
-
const badge = await inApp.countUnread(user);
|
|
38
|
-
|
|
39
|
-
// one notification — for a detail view (recipient-scoped)
|
|
40
|
-
const one = await inApp.find(user, "ntf_123");
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
All read methods accept a cascade `Notifiable` model OR a raw `id` (string | number) — convenient when you only have an id without fetching the user.
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
await inApp.list("user_42");
|
|
47
|
-
await inApp.countUnread(123);
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
## Writes (recipient-scoped → no IDOR)
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
// mark ALL unread rows for this recipient as read
|
|
54
|
-
await inApp.markAsRead(user);
|
|
55
|
-
|
|
56
|
-
// mark ONE specific row — STILL scoped to this recipient
|
|
57
|
-
await inApp.markAsRead(user, "ntf_123");
|
|
58
|
-
|
|
59
|
-
// inverse
|
|
60
|
-
await inApp.markAsUnread(user, "ntf_123");
|
|
61
|
-
|
|
62
|
-
// delete / dismiss — one row, or clear all for this recipient
|
|
63
|
-
await inApp.dismiss(user, "ntf_123");
|
|
64
|
-
await inApp.dismiss(user);
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
The recipient-id is forced into the `where` clause. A controller that receives an `id` from the request and naively calls `inApp.markAsRead(currentUser, id)` cannot accidentally flip another user's row — even if the request contains a forged id. The mutation will simply update 0 rows.
|
|
68
|
-
|
|
69
|
-
## Behind the scenes — `columnMap` drives everything
|
|
70
|
-
|
|
71
|
-
The model declares its physical columns ONCE via `static columnMap`. The
|
|
72
|
-
shipped `DatabaseNotification` accessors and `BaseNotificationsRepository`
|
|
73
|
-
(read filter + write mapping) both derive from it, so they can never drift:
|
|
74
|
-
|
|
75
|
-
```ts
|
|
76
|
-
abstract class DatabaseNotification extends Model implements NotificationContract {
|
|
77
|
-
public static columnMap: NotificationColumnMap = {};
|
|
78
|
-
// recipientId / tenantId / isRead / readAt / markRead all read `columnMap`.
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
class BaseNotificationsRepository extends RepositoryManager {
|
|
82
|
-
// constructor resolves the model's columnMap → builds `filterBy` +
|
|
83
|
-
// createFor / markRead / markUnread / deleteFor from it.
|
|
84
|
-
public createFor(recipientId, input, tenantId?) { /* one insert, starts unread */ }
|
|
85
|
-
}
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
### `createFor` write side — no IDOR either (since 4.15.0)
|
|
89
|
-
|
|
90
|
-
`recipientId` is always the trusted argument, applied **after** the payload — `createFor`/`createManyFor` strip server-owned keys (`id`, `recipientId`, `tenant`, `readAt`, `isRead`, and their resolved `columnMap` physical column names) off `input` before merging. Before this, a caller passing an untyped payload straight through (request JSON, a channel's `send` args) could smuggle its own `recipientId` — or the raw physical column name — and write the notification into someone else's inbox. `type` / `title` / `body` / `payload` / `idempotencyKey` are untouched; you don't need to sanitize `input` yourself.
|
|
91
|
-
|
|
92
|
-
### `columnMap` — map logical roles → your columns
|
|
93
|
-
|
|
94
|
-
```ts
|
|
95
|
-
export type NotificationColumnMap = {
|
|
96
|
-
recipient?: string; // recipient FK. Default "user_id"
|
|
97
|
-
tenant?: string; // multi-tenant scope, written from the recipient. Omit → single-tenant
|
|
98
|
-
readAt?: string; // read-timestamp column
|
|
99
|
-
isRead?: string; // read-flag column (indexed)
|
|
100
|
-
};
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
**Read-state is chosen by which keys are PRESENT:**
|
|
104
|
-
|
|
105
|
-
- `readAt` only → unread = `read_at IS NULL`; marking read stamps it.
|
|
106
|
-
- `isRead` only → unread = `is_read = false`; no timestamp.
|
|
107
|
-
- both → `is_read` is the indexed filter flag, `read_at` records WHEN.
|
|
108
|
-
|
|
109
|
-
Declare neither and you get the `readAt`-only default — so a model can omit
|
|
110
|
-
`columnMap` entirely. `unread` is the mode-agnostic read filter on `inApp` /
|
|
111
|
-
the repo; there is no `isRead` filter key to remember.
|
|
112
|
-
|
|
113
|
-
## Your model — declare `table`, `schema`, and `columnMap`
|
|
114
|
-
|
|
115
|
-
```ts title="src/app/notifications/notification.model.ts"
|
|
116
|
-
import { RegisterModel } from "@warlock.js/cascade";
|
|
117
|
-
import { v } from "@warlock.js/seal";
|
|
118
|
-
import { DatabaseNotification, type NotificationColumnMap } from "@warlock.js/notifications";
|
|
119
|
-
|
|
120
|
-
// Mirrors the migration columns; cascade validates + casts every write.
|
|
121
|
-
const notificationSchema = v.object({
|
|
122
|
-
user_id: v.string(),
|
|
123
|
-
type: v.string(),
|
|
124
|
-
title: v.string(),
|
|
125
|
-
body: v.string().nullish(),
|
|
126
|
-
payload: v.record(v.any()).nullish(),
|
|
127
|
-
read_at: v.date().nullish(),
|
|
128
|
-
idempotency_key: v.string().nullish(),
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
@RegisterModel()
|
|
132
|
-
export class Notification extends DatabaseNotification {
|
|
133
|
-
public static table = "notifications";
|
|
134
|
-
public static schema = notificationSchema;
|
|
135
|
-
// read_at-only, single-tenant. Add `tenant: "organization_id"` for multi-tenant,
|
|
136
|
-
// swap to `isRead: "is_read"` (or add it) to change the read-state representation.
|
|
137
|
-
public static columnMap: NotificationColumnMap = { readAt: "read_at" };
|
|
138
|
-
}
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
To change columns, edit `columnMap` — the migration (`notificationColumns`), the
|
|
142
|
-
repo filter, and the accessors all follow. For a different driver's naming
|
|
143
|
-
(e.g. MongoDB camelCase), name the columns in `columnMap`
|
|
144
|
-
(`{ recipient: "userId", readAt: "readAt" }`).
|
|
145
|
-
|
|
146
|
-
## Custom repository (advanced, optional)
|
|
147
|
-
|
|
148
|
-
`columnMap` already covers column naming. Subclass only for EXTRA query methods:
|
|
149
|
-
|
|
150
|
-
```ts title="src/app/notifications/notifications.repository.ts"
|
|
151
|
-
import { BaseNotificationsRepository } from "@warlock.js/notifications";
|
|
152
|
-
import { Notification } from "./notification.model";
|
|
153
|
-
|
|
154
|
-
export class NotificationsRepository extends BaseNotificationsRepository<Notification> {
|
|
155
|
-
public source = Notification; // its columnMap still drives filter + write mapping
|
|
156
|
-
// …extra methods specific to your app
|
|
157
|
-
}
|
|
158
|
-
export const notificationsRepository = new NotificationsRepository();
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
Then swap config:
|
|
162
|
-
|
|
163
|
-
```ts
|
|
164
|
-
database: inApp.configure({ repository: notificationsRepository }),
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
## See also
|
|
168
|
-
|
|
169
|
-
- [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
|
|
170
|
-
- [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — wire `inApp.configure` into the config.
|
|
171
|
-
- [`write-notification-migration/SKILL.md`](../write-notification-migration/SKILL.md) — `notificationColumns(Notification)`, named from `columnMap`.
|
|
172
|
-
- [`define-notification/SKILL.md`](../define-notification/SKILL.md) — create rows via the database channel.
|
|
1
|
+
---
|
|
2
|
+
name: use-in-app
|
|
3
|
+
description: 'Use the `inApp` facade for the in-app/database channel''s READ side: `inApp.configure({ model: Notification })` or `{ repository: myRepo }` binds the store AND returns the channel; `inApp.list / listUnread / countUnread / markAsRead / markAsUnread` are RECIPIENT-SCOPED by construction (no IDOR — `markAsRead(user, id)` cannot flip another user''s row even with a foreign id). `countUnread` uses `RepositoryManager.countCached` for the badge fast-path. Triggers: `inApp.configure`, `inApp.list`, `inApp.listUnread`, `inApp.countUnread`, `inApp.markAsRead`, `inApp.markAsUnread`, `BaseNotificationsRepository`, `DatabaseNotification`, `NotificationsFilter`, "in-app notifications", "mark notification as read", "unread count", "notification badge", "IDOR-safe markAsRead"; typical import `import { inApp } from "@warlock.js/notifications"`. Skip: writing notifications (use `defineNotification` or `notify.database`) — `@warlock.js/notifications/define-notification/SKILL.md`; the migration columns — `@warlock.js/notifications/write-notification-migration/SKILL.md`; wiring it into config — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `inApp` — in-app notification read API
|
|
7
|
+
|
|
8
|
+
The in-app database channel has two sides:
|
|
9
|
+
|
|
10
|
+
- **Write side** — `notify.database(user, {...})` and the database channel inside `defineNotification` create rows.
|
|
11
|
+
- **Read side** — `inApp.list` / `listUnread` / `countUnread` / `markAsRead` / `markAsUnread` query and mutate.
|
|
12
|
+
|
|
13
|
+
`inApp.configure({ model | repository })` binds the same repository for both sides, so cache invalidation just works.
|
|
14
|
+
|
|
15
|
+
## Configure (once, in `config/notifications.ts`)
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// 90% case — pass the model class; default repo built internally.
|
|
19
|
+
database: inApp.configure({ model: Notification }),
|
|
20
|
+
|
|
21
|
+
// 10% case — pass a custom repo.
|
|
22
|
+
database: inApp.configure({ repository: notificationsRepository }),
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Reads
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { inApp } from "@warlock.js/notifications";
|
|
29
|
+
|
|
30
|
+
// all rows for this recipient (paginated via the repo's standard `list`)
|
|
31
|
+
const all = await inApp.list(user);
|
|
32
|
+
|
|
33
|
+
// unread-only — pass `filter` to add type/etc. constraints
|
|
34
|
+
const unread = await inApp.listUnread(user, { type: "order.shipped" });
|
|
35
|
+
|
|
36
|
+
// cached unread count — backs the badge
|
|
37
|
+
const badge = await inApp.countUnread(user);
|
|
38
|
+
|
|
39
|
+
// one notification — for a detail view (recipient-scoped)
|
|
40
|
+
const one = await inApp.find(user, "ntf_123");
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
All read methods accept a cascade `Notifiable` model OR a raw `id` (string | number) — convenient when you only have an id without fetching the user.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
await inApp.list("user_42");
|
|
47
|
+
await inApp.countUnread(123);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Writes (recipient-scoped → no IDOR)
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// mark ALL unread rows for this recipient as read
|
|
54
|
+
await inApp.markAsRead(user);
|
|
55
|
+
|
|
56
|
+
// mark ONE specific row — STILL scoped to this recipient
|
|
57
|
+
await inApp.markAsRead(user, "ntf_123");
|
|
58
|
+
|
|
59
|
+
// inverse
|
|
60
|
+
await inApp.markAsUnread(user, "ntf_123");
|
|
61
|
+
|
|
62
|
+
// delete / dismiss — one row, or clear all for this recipient
|
|
63
|
+
await inApp.dismiss(user, "ntf_123");
|
|
64
|
+
await inApp.dismiss(user);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The recipient-id is forced into the `where` clause. A controller that receives an `id` from the request and naively calls `inApp.markAsRead(currentUser, id)` cannot accidentally flip another user's row — even if the request contains a forged id. The mutation will simply update 0 rows.
|
|
68
|
+
|
|
69
|
+
## Behind the scenes — `columnMap` drives everything
|
|
70
|
+
|
|
71
|
+
The model declares its physical columns ONCE via `static columnMap`. The
|
|
72
|
+
shipped `DatabaseNotification` accessors and `BaseNotificationsRepository`
|
|
73
|
+
(read filter + write mapping) both derive from it, so they can never drift:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
abstract class DatabaseNotification extends Model implements NotificationContract {
|
|
77
|
+
public static columnMap: NotificationColumnMap = {};
|
|
78
|
+
// recipientId / tenantId / isRead / readAt / markRead all read `columnMap`.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class BaseNotificationsRepository extends RepositoryManager {
|
|
82
|
+
// constructor resolves the model's columnMap → builds `filterBy` +
|
|
83
|
+
// createFor / markRead / markUnread / deleteFor from it.
|
|
84
|
+
public createFor(recipientId, input, tenantId?) { /* one insert, starts unread */ }
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `createFor` write side — no IDOR either (since 4.15.0)
|
|
89
|
+
|
|
90
|
+
`recipientId` is always the trusted argument, applied **after** the payload — `createFor`/`createManyFor` strip server-owned keys (`id`, `recipientId`, `tenant`, `readAt`, `isRead`, and their resolved `columnMap` physical column names) off `input` before merging. Before this, a caller passing an untyped payload straight through (request JSON, a channel's `send` args) could smuggle its own `recipientId` — or the raw physical column name — and write the notification into someone else's inbox. `type` / `title` / `body` / `payload` / `idempotencyKey` are untouched; you don't need to sanitize `input` yourself.
|
|
91
|
+
|
|
92
|
+
### `columnMap` — map logical roles → your columns
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
export type NotificationColumnMap = {
|
|
96
|
+
recipient?: string; // recipient FK. Default "user_id"
|
|
97
|
+
tenant?: string; // multi-tenant scope, written from the recipient. Omit → single-tenant
|
|
98
|
+
readAt?: string; // read-timestamp column
|
|
99
|
+
isRead?: string; // read-flag column (indexed)
|
|
100
|
+
};
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Read-state is chosen by which keys are PRESENT:**
|
|
104
|
+
|
|
105
|
+
- `readAt` only → unread = `read_at IS NULL`; marking read stamps it.
|
|
106
|
+
- `isRead` only → unread = `is_read = false`; no timestamp.
|
|
107
|
+
- both → `is_read` is the indexed filter flag, `read_at` records WHEN.
|
|
108
|
+
|
|
109
|
+
Declare neither and you get the `readAt`-only default — so a model can omit
|
|
110
|
+
`columnMap` entirely. `unread` is the mode-agnostic read filter on `inApp` /
|
|
111
|
+
the repo; there is no `isRead` filter key to remember.
|
|
112
|
+
|
|
113
|
+
## Your model — declare `table`, `schema`, and `columnMap`
|
|
114
|
+
|
|
115
|
+
```ts title="src/app/notifications/notification.model.ts"
|
|
116
|
+
import { RegisterModel } from "@warlock.js/cascade";
|
|
117
|
+
import { v } from "@warlock.js/seal";
|
|
118
|
+
import { DatabaseNotification, type NotificationColumnMap } from "@warlock.js/notifications";
|
|
119
|
+
|
|
120
|
+
// Mirrors the migration columns; cascade validates + casts every write.
|
|
121
|
+
const notificationSchema = v.object({
|
|
122
|
+
user_id: v.string(),
|
|
123
|
+
type: v.string(),
|
|
124
|
+
title: v.string(),
|
|
125
|
+
body: v.string().nullish(),
|
|
126
|
+
payload: v.record(v.any()).nullish(),
|
|
127
|
+
read_at: v.date().nullish(),
|
|
128
|
+
idempotency_key: v.string().nullish(),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
@RegisterModel()
|
|
132
|
+
export class Notification extends DatabaseNotification {
|
|
133
|
+
public static table = "notifications";
|
|
134
|
+
public static schema = notificationSchema;
|
|
135
|
+
// read_at-only, single-tenant. Add `tenant: "organization_id"` for multi-tenant,
|
|
136
|
+
// swap to `isRead: "is_read"` (or add it) to change the read-state representation.
|
|
137
|
+
public static columnMap: NotificationColumnMap = { readAt: "read_at" };
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
To change columns, edit `columnMap` — the migration (`notificationColumns`), the
|
|
142
|
+
repo filter, and the accessors all follow. For a different driver's naming
|
|
143
|
+
(e.g. MongoDB camelCase), name the columns in `columnMap`
|
|
144
|
+
(`{ recipient: "userId", readAt: "readAt" }`).
|
|
145
|
+
|
|
146
|
+
## Custom repository (advanced, optional)
|
|
147
|
+
|
|
148
|
+
`columnMap` already covers column naming. Subclass only for EXTRA query methods:
|
|
149
|
+
|
|
150
|
+
```ts title="src/app/notifications/notifications.repository.ts"
|
|
151
|
+
import { BaseNotificationsRepository } from "@warlock.js/notifications";
|
|
152
|
+
import { Notification } from "./notification.model";
|
|
153
|
+
|
|
154
|
+
export class NotificationsRepository extends BaseNotificationsRepository<Notification> {
|
|
155
|
+
public source = Notification; // its columnMap still drives filter + write mapping
|
|
156
|
+
// …extra methods specific to your app
|
|
157
|
+
}
|
|
158
|
+
export const notificationsRepository = new NotificationsRepository();
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Then swap config:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
database: inApp.configure({ repository: notificationsRepository }),
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## See also
|
|
168
|
+
|
|
169
|
+
- [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
|
|
170
|
+
- [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — wire `inApp.configure` into the config.
|
|
171
|
+
- [`write-notification-migration/SKILL.md`](../write-notification-migration/SKILL.md) — `notificationColumns(Notification)`, named from `columnMap`.
|
|
172
|
+
- [`define-notification/SKILL.md`](../define-notification/SKILL.md) — create rows via the database channel.
|