@antelopejs/interface-dms 0.7.1 → 0.7.3

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 (75) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +70 -68
  3. package/dist/auth/db/models/sessions.model.d.ts +1 -0
  4. package/dist/auth/db/models/userExternalIdentities.model.d.ts +1 -0
  5. package/dist/auth/db/models/users.model.d.ts +1 -0
  6. package/dist/base/block-registry/index.d.ts +1 -0
  7. package/dist/base/block-registry/index.js +1 -0
  8. package/dist/base/block-registry/index.js.map +1 -1
  9. package/dist/base/block-registry/presentation.d.ts +21 -0
  10. package/dist/base/block-registry/presentation.js +108 -0
  11. package/dist/base/block-registry/presentation.js.map +1 -0
  12. package/dist/base/block-types.d.ts +1 -0
  13. package/dist/base/block-types.js +1 -0
  14. package/dist/base/block-types.js.map +1 -1
  15. package/dist/base/live-toggle.d.ts +2 -2
  16. package/dist/base/period-selector.d.ts +2 -2
  17. package/dist/base/placeholder.js +2 -2
  18. package/dist/base/placeholder.js.map +1 -1
  19. package/dist/base/table-view/schema.d.ts +4 -4
  20. package/dist/db/internal/exportJobs.model.d.ts +1 -0
  21. package/dist/db/internal/inviteResolutions.model.d.ts +1 -0
  22. package/dist/db/internal/tenantLifecycle.model.d.ts +1 -0
  23. package/dist/db/models/roles.model.d.ts +1 -0
  24. package/dist/db/models/tenantMembers.model.d.ts +1 -0
  25. package/dist/db/models/tenants.model.d.ts +1 -0
  26. package/dist/db/models/user_invites.model.d.ts +1 -0
  27. package/docs/.navigation.yml +2 -0
  28. package/docs/1.core/.navigation.yml +2 -0
  29. package/docs/1.core/1.introduction.md +169 -0
  30. package/docs/1.core/10.layout-banners.md +133 -0
  31. package/docs/1.core/2.pages-and-modules.md +649 -0
  32. package/docs/1.core/3.components.md +287 -0
  33. package/docs/1.core/4.permissions.md +279 -0
  34. package/docs/1.core/5.tenancy.md +358 -0
  35. package/docs/1.core/6.invites.md +239 -0
  36. package/docs/1.core/7.hooks.md +354 -0
  37. package/docs/1.core/8.realtime.md +147 -0
  38. package/docs/1.core/9.quick-actions.md +115 -0
  39. package/docs/2.auth/.navigation.yml +2 -0
  40. package/docs/2.auth/1.introduction.md +75 -0
  41. package/docs/2.auth/2.authentication.md +222 -0
  42. package/docs/2.auth/3.tokens-and-sessions.md +352 -0
  43. package/docs/2.auth/4.users.md +256 -0
  44. package/docs/2.auth/5.external-identities.md +122 -0
  45. package/docs/3.base/.navigation.yml +2 -0
  46. package/docs/3.base/1.introduction.md +202 -0
  47. package/docs/3.base/10.display-blocks.md +136 -0
  48. package/docs/3.base/2.types.md +402 -0
  49. package/docs/3.base/3.forms.md +349 -0
  50. package/docs/3.base/4.tables.md +608 -0
  51. package/docs/3.base/5.charts-and-widgets.md +466 -0
  52. package/docs/3.base/6.layout.md +437 -0
  53. package/docs/3.base/8.data-types.md +361 -0
  54. package/docs/3.base/9.helpers.md +414 -0
  55. package/docs/4.notifications/.navigation.yml +2 -0
  56. package/docs/4.notifications/1.introduction.md +81 -0
  57. package/docs/4.notifications/2.categories-and-subjects.md +214 -0
  58. package/docs/4.notifications/3.sending.md +285 -0
  59. package/docs/5.html-render/.navigation.yml +2 -0
  60. package/docs/5.html-render/1.introduction.md +386 -0
  61. package/docs/5.html-render/2.built-in-emails.md +157 -0
  62. package/docs/images/emails-admin-invite.png +0 -0
  63. package/docs/images/emails-change-notice.png +0 -0
  64. package/docs/images/emails-change-verification.png +0 -0
  65. package/docs/images/emails-digest-logo-dark.png +0 -0
  66. package/docs/images/emails-digest-logo-light.png +0 -0
  67. package/docs/images/emails-digest.png +0 -0
  68. package/docs/images/emails-export-partial.png +0 -0
  69. package/docs/images/emails-export-ready.png +0 -0
  70. package/docs/images/emails-reset-password.png +0 -0
  71. package/docs/images/emails-two-factor.png +0 -0
  72. package/docs/images/emails-user-validation.png +0 -0
  73. package/docs/index.md +109 -0
  74. package/docs/tsconfig.json +11 -0
  75. package/package.json +25 -18
@@ -0,0 +1,214 @@
1
+ ---
2
+ title: "Categories and Subjects"
3
+ description: "Every notification is filed under a subject, and every subject belongs to a category."
4
+ ---
5
+
6
+ # Categories and Subjects
7
+
8
+ ## Overview
9
+
10
+ Every notification is filed under a **subject**, and every subject belongs to a **category**. That two-level taxonomy is what the notification settings screen groups by, and what user preferences are keyed on: a preference entry exists per `categoryId:subjectId` pair.
11
+
12
+ You declare categories and subjects by calling `NotificationCategory()` and `NotificationSubject()`. Both calls register the declaration with the DMS and return the info object, which you keep as a module-level constant. You pass the category constant to `NotificationSubject()`, and the subject constant to the builder's `.subject()` when you send.
13
+
14
+ Declarations are values, not decorators: they run when the module that contains them is imported. Declare them at the top level of a module that is imported during startup, so the taxonomy is complete before the first notification is sent.
15
+
16
+ The [notification preferences walkthrough](/docs/auth-and-tenancy/authentication#notification-preferences) shows the System category expanded, its locked General subject, and the Playground category and subject switched off. The Email column is marked Soon: these controls currently manage in-app delivery, not transactional authentication emails.
17
+
18
+ ## Declaring a Category
19
+
20
+ `NotificationCategory(id, category)` registers a category and returns the resulting `NotificationCategoryInfo`.
21
+
22
+ ```typescript
23
+ import { NotificationCategory } from "@antelopejs/interface-dms/notifications";
24
+
25
+ export const shopCategory = NotificationCategory("shop", {
26
+ labelKey: "shop.notifications.categories.shop",
27
+ descriptionKey: "shop.notifications.categories.shop_desc",
28
+ icon: "i-ph-shopping-cart",
29
+ });
30
+ ```
31
+
32
+ | Parameter | Description |
33
+ | --------- | ----------- |
34
+ | `id` | Identifier of the category. It becomes the first half of every preference key under this category. |
35
+ | `category` | The rest of the `NotificationCategoryInfo` object — every field except `id`. |
36
+
37
+ ### NotificationCategoryInfo
38
+
39
+ | Option | Type | Description |
40
+ | ------ | ---- | ----------- |
41
+ | `id` | `string` | Category identifier. Supplied through the first argument of `NotificationCategory()`. |
42
+ | `labelKey` | `string` | Translation key for the category name. |
43
+ | `descriptionKey` | `string` (optional) | Translation key for a longer description. |
44
+ | `icon` | `string` | Icon name, in the icon syntax used across the dashboard (for example `i-ph-shopping-cart`). |
45
+ | `togglePermission` | [`TogglePermission`](#toggle-permissions) (optional) | Parent toggle stance applied when evaluating this category's subjects. It does not lock the category's own switch. |
46
+ | `tagKey` | `string` (optional) | Translation key of a short module tag (for example `SaaS`) shown next to the category in the settings screen and on each of its notifications in the inbox. Without it, the inbox tags a notification with its subject's label. |
47
+
48
+ Registering a second category with an `id` that is already taken is ignored: the first declaration wins.
49
+
50
+ ## Declaring a Subject
51
+
52
+ `NotificationSubject(id, subject)` registers a subject under an already-declared category and returns the resulting `NotificationSubjectInfo`.
53
+
54
+ ```typescript
55
+ import {
56
+ NotificationCategory,
57
+ NotificationSubject,
58
+ } from "@antelopejs/interface-dms/notifications";
59
+
60
+ export const shopCategory = NotificationCategory("shop", {
61
+ labelKey: "shop.notifications.categories.shop",
62
+ icon: "i-ph-shopping-cart",
63
+ });
64
+
65
+ export const orderSubject = NotificationSubject("orders", {
66
+ category: shopCategory,
67
+ labelKey: "shop.notifications.subjects.orders",
68
+ descriptionKey: "shop.notifications.subjects.orders_desc",
69
+ togglePermission: "default",
70
+ });
71
+
72
+ export const stockSubject = NotificationSubject("stock", {
73
+ category: shopCategory,
74
+ labelKey: "shop.notifications.subjects.stock",
75
+ togglePermission: "allowed",
76
+ });
77
+ ```
78
+
79
+ | Parameter | Description |
80
+ | --------- | ----------- |
81
+ | `id` | Identifier of the subject. It is unique within its category, not globally: the preference key is `categoryId:subjectId`. |
82
+ | `subject` | The rest of the `NotificationSubjectInfo` object — every field except `id`. |
83
+
84
+ ### NotificationSubjectInfo
85
+
86
+ | Option | Type | Description |
87
+ | ------ | ---- | ----------- |
88
+ | `id` | `string` | Subject identifier, unique within the category. Supplied through the first argument of `NotificationSubject()`. |
89
+ | `category` | `NotificationCategoryInfo` | The category this subject belongs to — the value returned by `NotificationCategory()`. |
90
+ | `labelKey` | `string` | Translation key for the subject name. |
91
+ | `descriptionKey` | `string` (optional) | Translation key for a longer description. |
92
+ | `togglePermission` | [`TogglePermission`](#toggle-permissions) (optional) | Whether users may turn this subject off, read together with the category's value. |
93
+ | `badgeKey` | `string` (optional) | Translation key of a badge shown next to the subject label in the settings screen (for example `Developer mode`). |
94
+
95
+ A subject carries the whole category object, not just its id, so `subject.category.id` is always reachable from a subject you hold. That is how a send resolves the preference key from nothing but the subject you passed to the builder.
96
+
97
+ Registering a second subject with an `id` already taken inside the same category is ignored, the same way categories are.
98
+
99
+ ### Declaration Order
100
+
101
+ The category has to be registered before any of its subjects: a subject whose category is not registered yet is rejected and never enters the registry, so nothing sent against it is delivered. Registrations run in call order, so declaring the category above its subjects in the same file — as in the example above — is enough.
102
+
103
+ Registrations are scoped to the module that made them: when that module is unloaded, its categories and subjects are unregistered.
104
+
105
+ ## Toggle Permissions
106
+
107
+ `TogglePermission` controls whether a user is allowed to opt out of a subject from the notification settings screen.
108
+
109
+ ```typescript
110
+ type TogglePermission = "allowed" | "forbidden" | "default";
111
+ ```
112
+
113
+ The field is optional on both `NotificationCategoryInfo` and `NotificationSubjectInfo`, and the settings screen locks a subject's switch only when both of these hold:
114
+
115
+ - the subject declares `togglePermission: "forbidden"`, and
116
+ - its category declares `togglePermission` as `"default"` or `"forbidden"`.
117
+
118
+ Every other combination leaves the subject toggleable. A category's own switch is never locked: the value you put on a category acts as the parent half of the rule above and nothing else.
119
+
120
+ | Value | Meaning on a subject | Meaning on a category |
121
+ | ----- | -------------------- | --------------------- |
122
+ | `"forbidden"` | The user may not turn the subject off, provided the category declares `"default"` or `"forbidden"`. | Enables the subject-side lock. The category itself stays toggleable. |
123
+ | `"default"` | The subject stays toggleable. | Enables the subject-side lock, same as `"forbidden"`. |
124
+ | `"allowed"` | The subject stays toggleable, same as `"default"`. | Leaves every subject toggleable, same as omitting the field. |
125
+
126
+ So for a notification a user must always receive, declare `togglePermission: "forbidden"` on the subject **and** `togglePermission: "default"` on the category that holds it. `SystemCategory` declares `"default"`, which is what makes `GeneralSubject` mandatory.
127
+
128
+ ```typescript
129
+ import {
130
+ NotificationCategory,
131
+ NotificationSubject,
132
+ } from "@antelopejs/interface-dms/notifications";
133
+
134
+ export const alertCategory = NotificationCategory("alerts", {
135
+ labelKey: "shop.notifications.categories.alerts",
136
+ icon: "i-ph-warning",
137
+ togglePermission: "default", // required for the forbidden subject below to lock
138
+ });
139
+
140
+ export const outageSubject = NotificationSubject("outage", {
141
+ category: alertCategory,
142
+ labelKey: "shop.notifications.subjects.outage",
143
+ togglePermission: "forbidden",
144
+ });
145
+ ```
146
+
147
+ `togglePermission` only governs what the settings screen offers. It does not change what `.toUser()` and the other dispatch methods do with a preference the user has already stored — see [Delivery Rules](/docs/interfaces/notifications/sending#delivery-rules).
148
+
149
+ ## Read Scope
150
+
151
+ `ReadScope` describes how the read state of a multi-recipient notification behaves.
152
+
153
+ ```typescript
154
+ type ReadScope = "individual" | "shared";
155
+ ```
156
+
157
+ | Value | Meaning |
158
+ | ----- | ------- |
159
+ | `"individual"` | Each recipient gets their own copy with its own read state. This is the default. |
160
+ | `"shared"` | The copies are grouped: once one recipient reads the notification, it counts as read for the whole group. |
161
+
162
+ The scope is chosen per send, not per subject; you pass it through `SendOptions` on the multi-recipient dispatch methods. See [Send Options](/docs/interfaces/notifications/sending#send-options).
163
+
164
+ ## Built-in Categories and Subjects
165
+
166
+ The DMS declares one category and five subjects of its own. Import them and pass them to `.subject()` when your notification fits one of them, instead of declaring a near-duplicate.
167
+
168
+ ```typescript
169
+ import {
170
+ AccountSubject,
171
+ AutomationSubject,
172
+ CollaborationSubject,
173
+ GeneralSubject,
174
+ SecuritySubject,
175
+ SystemCategory,
176
+ } from "@antelopejs/interface-dms/notifications";
177
+ ```
178
+
179
+ `SystemCategory` is the category all five subjects belong to. Its id is `system`, its icon is `i-ph-gear`, and its `togglePermission` is `"default"` — the parent value that lets `GeneralSubject`'s `"forbidden"` take effect.
180
+
181
+ | Constant | Id | Toggle permission | Used for |
182
+ | -------- | -- | ----------------- | -------- |
183
+ | `GeneralSubject` | `general` | `"forbidden"` | Unclassified platform messages. Users cannot opt out. |
184
+ | `SecuritySubject` | `security` | `"default"` | Sign-ins, password and email changes, two-factor changes, backup-code regeneration. |
185
+ | `AccountSubject` | `account` | `"default"` | Account lifecycle messages, such as the welcome notification. |
186
+ | `CollaborationSubject` | `collaboration` | `"default"` | Messages about other people in the workspace, such as a collaborator joining. |
187
+ | `AutomationSubject` | `automation` | `"default"` | Messages emitted by automations. |
188
+
189
+ Their labels and descriptions use the `dms.notifications.*` translation keys shipped with the DMS.
190
+
191
+ ## Importing the Types
192
+
193
+ The declaration functions and the built-in constants are exported from the interface root. The type definitions live in the `types` subpath:
194
+
195
+ | Symbol | Import from |
196
+ | ------ | ----------- |
197
+ | `NotificationCategory`, `NotificationSubject`, `SystemCategory`, `GeneralSubject`, `SecuritySubject`, `AccountSubject`, `CollaborationSubject`, `AutomationSubject`, `NotificationTone` | `@antelopejs/interface-dms/notifications` |
198
+ | `NotificationCategoryInfo`, `NotificationSubjectInfo`, `NotificationTone`, `TogglePermission`, `ReadScope` | `@antelopejs/interface-dms/notifications/types` |
199
+
200
+ ```typescript
201
+ import type { NotificationSubjectInfo } from "@antelopejs/interface-dms/notifications/types";
202
+
203
+ interface NotificationTemplate {
204
+ icon: string;
205
+ subject: NotificationSubjectInfo;
206
+ linkTo: string;
207
+ }
208
+ ```
209
+
210
+ Typing your own helpers against `NotificationSubjectInfo` is the usual reason to reach for the `types` subpath: it lets a template table or a helper function accept any subject, built-in or your own.
211
+
212
+ ## Next Steps
213
+
214
+ - [Sending Notifications](/docs/interfaces/notifications/sending) - Build a notification against a subject and dispatch it to users, roles, or everyone
@@ -0,0 +1,285 @@
1
+ ---
2
+ title: "Sending Notifications"
3
+ description: "Build a notification, then send it to users, roles or everyone, with delivery rules and send options."
4
+ ---
5
+
6
+ # Sending Notifications
7
+
8
+ ## Overview
9
+
10
+ Sending happens in two steps. `Notification()` starts a builder you fill field by field; `.build()` closes it and gives you a `SendableNotification`, which carries the dispatch methods.
11
+
12
+ For the visible result, see the [single-recipient notification and second-session update](/docs/auth-and-tenancy/authentication#read-notifications). The walkthrough distinguishes delivery from read state and shows [a disabled subject preventing a new delivery](/docs/auth-and-tenancy/authentication#notification-preferences).
13
+
14
+ ```typescript
15
+ import {
16
+ GeneralSubject,
17
+ Notification,
18
+ } from "@antelopejs/interface-dms/notifications";
19
+
20
+ await Notification()
21
+ .icon("i-ph-bell")
22
+ .title("Deployment finished")
23
+ .description("The staging deployment completed successfully.")
24
+ .subject(GeneralSubject)
25
+ .build()
26
+ .toUser(userId);
27
+ ```
28
+
29
+ A `SendableNotification` is immutable and holds no recipient, so you can build once and dispatch several times.
30
+
31
+ | Method | Signature | Description |
32
+ | ------ | --------- | ----------- |
33
+ | `toUser` | `(userId: string, options?: SendOptions) => Promise<void>` | Send to one user. |
34
+ | `toUsers` | `(userIds: string[], options?: SendOptions) => Promise<void>` | Send to a list of users. |
35
+ | `toUsersIdempotently` | `(userIds: string[], idempotencyKey: string, options?: SendOptions) => Promise<void>` | Send a durable event once per recipient, including after dismissal. |
36
+ | `toRoles` | `(roleIds: string[], options?: SendOptions) => Promise<void>` | Send to every user holding at least one of the roles. |
37
+ | `broadcast` | `(options?: SendOptions) => Promise<void>` | Send to every user. |
38
+
39
+ ## Building a Notification
40
+
41
+ `Notification()` takes no arguments and returns a fresh `NotificationBuilder`. Every setter stores its value and returns the builder, so calls chain in any order.
42
+
43
+ ```typescript
44
+ import { Notification } from "@antelopejs/interface-dms/notifications";
45
+ import { orderSubject } from "./notifications";
46
+
47
+ const shipped = Notification()
48
+ .icon("i-ph-truck")
49
+ .title("$shop.notifications.order_shipped.title")
50
+ .description("$shop.notifications.order_shipped.description")
51
+ .params({ orderRef: "A-1043" })
52
+ .subject(orderSubject)
53
+ .linkTo("/shop/orders")
54
+ .build();
55
+ ```
56
+
57
+ | Method | Type | Description |
58
+ | ------ | ---- | ----------- |
59
+ | `.icon(value)` | `string` | Icon shown next to the notification, in the dashboard icon syntax (for example `i-ph-truck`). Required. |
60
+ | `.title(value)` | `string` | Title line. Required. |
61
+ | `.description(value)` | `string` | Body line. Required. |
62
+ | `.subject(subject)` | `NotificationSubjectInfo` | The subject the notification is filed under, which decides the category and the user preference it is gated on. Required. |
63
+ | `.linkTo(value)` | `string` | Destination the notification links to, such as `/settings/user/profile`. Optional. |
64
+ | `.params(value)` | `Record<string, string \| number>` | Interpolation values carried alongside the notification. Optional. |
65
+ | `.tone(value)` | `NotificationTone` (`"neutral"`, `"primary"`, `"success"`, `"warning"`, `"error"`) | Colour of the icon well the inbox lists the notification with. Optional: without it the well is `primary` while unread and neutral once read. |
66
+
67
+ The subject comes from [Categories and Subjects](/docs/interfaces/notifications/categories-and-subjects) — either one you declared with `NotificationSubject()` or one of the built-in constants.
68
+
69
+ Titles and descriptions can be literal text or, following the DMS translation convention, a `$`-prefixed translation key; in the latter case the values passed to `.params()` are the interpolation values for that key. The DMS's own account notifications are written that way:
70
+
71
+ ```typescript
72
+ import {
73
+ Notification,
74
+ SecuritySubject,
75
+ } from "@antelopejs/interface-dms/notifications";
76
+
77
+ await Notification()
78
+ .icon("i-ph-sign-in")
79
+ .title("$dms.notifications.messages.new_login.title")
80
+ .description("$dms.notifications.messages.new_login.description")
81
+ .params({ device: "Firefox on Linux", origin: " (203.0.113.4)" })
82
+ .subject(SecuritySubject)
83
+ .linkTo("/settings/user/profile")
84
+ .build()
85
+ .toUser(userId);
86
+ ```
87
+
88
+ ### Required Fields Are Checked by the Type System
89
+
90
+ `NotificationBuilder<Set>` tracks in its type parameter which fields you have supplied: `.icon()` returns `NotificationBuilder<Set | "icon">`, `.title()` adds `"title"`, and so on. `build()` is declared with a `this` parameter that only accepts a builder whose `Set` covers `RequiredFields`:
91
+
92
+ ```typescript
93
+ type RequiredFields = "icon" | "title" | "description" | "subject";
94
+ ```
95
+
96
+ So a chain that skips one of the four does not compile — `this` is typed `never` at the `build()` call:
97
+
98
+ ```typescript
99
+ // Compile error: description and subject were never supplied
100
+ const broken = Notification().icon("i-ph-bell").title("Hello").build();
101
+ ```
102
+
103
+ The check is purely static and happens at the call site. Nothing is validated again at runtime, and `.linkTo()` and `.params()` never affect it, and neither does `.tone()`.
104
+
105
+ `build()` returns a `SendableNotification` holding a frozen copy of the data, so reusing the builder afterwards cannot change a notification you already built.
106
+
107
+ ## Sending to a Single User
108
+
109
+ `toUser(userId, options?)` sends one copy to one user. It accepts an idempotency key; `readScope` has no effect for this method.
110
+
111
+ ```typescript
112
+ import { Controller, Post } from "@antelopejs/interface-api";
113
+ import { AuthRawUser } from "@antelopejs/interface-dms/auth";
114
+ import type { User } from "@antelopejs/interface-dms/auth/db";
115
+ import {
116
+ GeneralSubject,
117
+ Notification,
118
+ } from "@antelopejs/interface-dms/notifications";
119
+
120
+ export class NotificationDemoController extends Controller("/api/notification") {
121
+ @Post("send")
122
+ async send(@AuthRawUser() user: User) {
123
+ await Notification()
124
+ .icon("i-ph-bell")
125
+ .title("Notification sent")
126
+ .description("This notification was sent from a route handler.")
127
+ .subject(GeneralSubject)
128
+ .build()
129
+ .toUser(user._id);
130
+
131
+ return { success: true };
132
+ }
133
+ }
134
+ ```
135
+
136
+ ## Sending to Several Users
137
+
138
+ `toUsers(userIds, options?)` sends to a list of user ids. Duplicate ids in the list produce a single notification per user. The examples in this section and the two below dispatch the `shipped` notification built earlier on this page.
139
+
140
+ ```typescript
141
+ await shipped.toUsers([ownerId, managerId], { readScope: "shared" });
142
+ ```
143
+
144
+ ## Sending to Roles
145
+
146
+ `toRoles(roleIds, options?)` resolves the users that hold any of the given roles and sends to them. An empty `roleIds` array sends nothing, and so does a set of roles nobody holds.
147
+
148
+ ```typescript
149
+ await shipped.toRoles([adminRoleId], { readScope: "shared" });
150
+ ```
151
+
152
+ Role ids are the DMS role identifiers described in the DMS tenancy and permissions guide; membership is resolved across tenants, so a user holding the role in any tenant is included.
153
+
154
+ ## Broadcasting
155
+
156
+ `broadcast(options?)` sends to every user.
157
+
158
+ ```typescript
159
+ await shipped.broadcast();
160
+ ```
161
+
162
+ ## Send Options
163
+
164
+ Every dispatch method accepts an optional `SendOptions` object.
165
+
166
+ | Option | Type | Description |
167
+ | ------ | ---- | ----------- |
168
+ | `readScope` | `ReadScope` (optional) | Read-state grouping for a multi-recipient send. Defaults to `"individual"` — see [Read Scope](/docs/interfaces/notifications/categories-and-subjects#read-scope). |
169
+ | `idempotencyKey` | `string` (optional) | Stable event key, unique across producers: prefix it with your module ID. Reusing it for a recipient preserves the original notification and dismissal state. |
170
+ | `dedupe` | `boolean` (optional) | `false` stores every unkeyed send, even one identical to a notification the recipient got less than 10 seconds earlier. Defaults to `true`; see below. |
171
+
172
+ Pick `"shared"` for alerts that only need one person to act on them, and `"individual"` for anything each recipient has to see for themselves.
173
+
174
+ ```typescript
175
+ import type { SendOptions } from "@antelopejs/interface-dms/notifications/types";
176
+
177
+ const forEveryone: SendOptions = { readScope: "individual" };
178
+ const handleOnce: SendOptions = { readScope: "shared" };
179
+ ```
180
+
181
+ ## Repeated Sends
182
+
183
+ An unkeyed notification identical to one the same user received less than 10 seconds earlier is dropped: same recipient, title, description, params, link, category, subject, tone and icon. A form submitted twice, or a hook firing on every instance, leaves one row; two notices worded alike but filed under different subjects, or drawn with a different tone or icon, are two notices. A sender whose repeats are all meant to arrive (a counter ticking, a chat) passes `{ dedupe: false }` to any send method. The check holds across instances: the row ID derives from the recipient, the content and a 10-second window, so the database refuses the second insert, and a copy from the window before also counts. The same notification sent again later arrives as usual, and so does one whose params differ (a count, a timestamp). Keyed sends are deduplicated by their key instead, as described below.
184
+
185
+ ## Retrying Durable Events
186
+
187
+ Use the same event key on every outbox retry. The first delivery stored under a key wins: a later send under the same key returns without storing anything, even when its content differs (a count recomputed on retry, a timestamp), so a producer that keys a notification per period does not have to guard against a duplicate-key error. The explicit method is equivalent to passing `idempotencyKey` to `toUsers`; its key argument takes precedence over a key in `options`.
188
+
189
+ The key is shared by every module of the deployment: the DMS stores one row per key and recipient, whatever module or category sent it. Because a reused key counts as already delivered, two modules that happen to build the same key for the same user would lose the second notification without any error. Namespace your keys with your module ID (`mailing:digest:2026-10-13T09`, not `digest:2026-10-13T09`).
190
+
191
+ ```typescript
192
+ await shipped.toUsers([ownerId, managerId], { idempotencyKey: event.id });
193
+ await shipped.toUsersIdempotently([ownerId, managerId], event.id);
194
+ ```
195
+
196
+ The DMS derives a namespaced SHA-256 row ID from the event key and recipient ID and performs a plain atomic insert. After an insert error, it reads that exact ID and accepts only a matching stored delivery, including its subject, content, and read group. Other errors propagate so an outbox can retry; duplicate attempts never replace the row or reset its timestamps or read state.
197
+
198
+ Single and bulk dismissal retain keyed rows as hidden delivery receipts. Lists, previews, counts, individual lookups, and read operations exclude these rows; retries do not recreate them. Unkeyed notifications retain physical deletion and fresh delivery on every send outside the 10-second [repeat window](#repeated-sends). Receipts retain notification content indefinitely: do not hard-delete or expire these rows while their event keys can still be retried, and account for this retention when designing data-erasure policies.
199
+
200
+ Shared-read sends derive a stable group ID from the event key. Recipient membership and preferences are evaluated again on each attempt, so a retry can add previously missing recipients without duplicating successful deliveries. A recipient added after another recipient has already marked the group read starts unread; retries preserve existing recipients' state rather than replaying past read actions.
201
+
202
+ Realtime publishing remains best-effort and occurs only after a newly inserted delivery. A crash or lost acknowledgement can leave a stored notification without a realtime event; retrying does not replay realtime. Delivery gates still apply, and skipped recipients do not receive receipts.
203
+
204
+ Consumers that support older DMS packages must fail closed instead of passing an option an old runtime silently ignores. Feature-detect the explicit method and retain pending outbox work until the DMS package and runtime are upgraded together to a version containing it:
205
+
206
+ ```typescript
207
+ if (!("toUsersIdempotently" in shipped) || typeof shipped.toUsersIdempotently !== "function") {
208
+ throw new Error("DMS idempotent notification delivery is unavailable");
209
+ }
210
+ await shipped.toUsersIdempotently([ownerId, managerId], event.id);
211
+ ```
212
+
213
+ Every keyed dispatch also checks the implementation's `SupportsIdempotency` interface capability before sending. A new local builder paired with an old DMS implementation therefore rejects rather than silently delivering without deduplication.
214
+
215
+ ## Delivery Rules
216
+
217
+ The DMS applies the same rules to every dispatch method, since `toUsers`, `toRoles` and `broadcast` all resolve to per-user deliveries:
218
+
219
+ - **The subject must be registered.** A notification whose subject was never declared through `NotificationSubject()` is dropped. This is what makes registration order matter — see [Declaration Order](/docs/interfaces/notifications/categories-and-subjects#declaration-order).
220
+ - **The user preference is honored.** Delivery is skipped when the recipient has turned off the `categoryId:subjectId` pair in their notification settings. Preferences default to enabled, so a subject a user has never touched is delivered.
221
+ - **Skipping is silent.** A gated-out delivery is not an error: the promise resolves normally, and there is no per-recipient result to inspect.
222
+ - **Delivery is per recipient.** Each recipient gets their own stored notification. With `readScope: "shared"` those copies are tied together for read state; they are still separate rows.
223
+ - **Stored notifications are pushed live.** Each delivery is also published on the recipient's realtime stream, so the dashboard's badge and preview update without a reload.
224
+
225
+ Because a send can be skipped for reasons outside your control, treat notifications as best-effort: do not make a workflow depend on one having been stored. Wrapping the call so a failure cannot break the operation that triggered it is a common pattern:
226
+
227
+ ```typescript
228
+ import { Logging } from "@antelopejs/interface-core/logging";
229
+ import {
230
+ AccountSubject,
231
+ Notification,
232
+ } from "@antelopejs/interface-dms/notifications";
233
+
234
+ export async function notifyWelcome(userId: string, name: string) {
235
+ try {
236
+ await Notification()
237
+ .icon("i-ph-hand-waving")
238
+ .title("$dms.notifications.messages.welcome.title")
239
+ .description("$dms.notifications.messages.welcome.description")
240
+ .params({ name })
241
+ .subject(AccountSubject)
242
+ .linkTo("/settings/user/profile")
243
+ .build()
244
+ .toUser(userId);
245
+ } catch (error) {
246
+ Logging.Error(`Failed to notify "${userId}": ${String(error)}`);
247
+ }
248
+ }
249
+ ```
250
+
251
+ ## Importing the Types
252
+
253
+ `Notification()` is exported from the interface root, and the data types from the `types` subpath. The two classes are exported from the modules that define them, which you only need when you want to name them explicitly:
254
+
255
+ | Symbol | Import from |
256
+ | ------ | ----------- |
257
+ | `Notification` | `@antelopejs/interface-dms/notifications` |
258
+ | `NotificationData`, `SendOptions`, `RequiredFields` | `@antelopejs/interface-dms/notifications/types` |
259
+ | `NotificationBuilder` | `@antelopejs/interface-dms/notifications/builder` |
260
+ | `SendableNotification` | `@antelopejs/interface-dms/notifications/sendable` |
261
+
262
+ `NotificationData` is the shape the builder assembles — `icon`, `title`, `description` and `subject`, plus the optional `linkTo` and `params`. Naming it is useful when you carry notification content around before building it:
263
+
264
+ ```typescript
265
+ import { Notification } from "@antelopejs/interface-dms/notifications";
266
+ import type { NotificationData } from "@antelopejs/interface-dms/notifications/types";
267
+ import type { SendableNotification } from "@antelopejs/interface-dms/notifications/sendable";
268
+
269
+ function fromData(data: NotificationData): SendableNotification {
270
+ const builder = Notification()
271
+ .icon(data.icon)
272
+ .title(data.title)
273
+ .description(data.description)
274
+ .subject(data.subject);
275
+
276
+ if (data.linkTo) {
277
+ builder.linkTo(data.linkTo);
278
+ }
279
+ if (data.params) {
280
+ builder.params(data.params);
281
+ }
282
+
283
+ return builder.build();
284
+ }
285
+ ```
@@ -0,0 +1,2 @@
1
+ title: HTML render
2
+ icon: i-lucide-mail