@voltro/plugin-notifications 0.26.0 → 0.28.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/dist/index.d.ts CHANGED
@@ -160,6 +160,49 @@ export declare const inboxTable: Table<"_voltro_notification_inbox", FieldDefini
160
160
  * midnight, e.g. 22:00→08:00). An empty window (start === end) is never active. */
161
161
  export declare const inQuietHours: (qh: QuietHours, at: Date) => boolean;
162
162
 
163
+ /**
164
+ * WHO an inbox belongs to.
165
+ *
166
+ * `subject.id` is the framework's answer and it is not always the app's. A
167
+ * reporter's addressing unit is the EMPLOYEE: a shift change, an absence
168
+ * request or a task reminder is addressed to a person, and a person does not
169
+ * necessarily have an auth user. Their numbers, on 14 670 rows:
170
+ *
171
+ * employee WITH a userId 4 677 representable
172
+ * employee WITHOUT one 668 NOT — 4 people, 516 of those rows read,
173
+ * most recent 2026-06-19
174
+ *
175
+ * The 668 are live traffic, read by someone, and unaddressable under a
176
+ * subject-only model. Worse is what comes after a migration: every producer
177
+ * would resolve employee → userId and silently deliver NOTHING for anyone
178
+ * without an auth user — the exact failure this plugin's own docstring warns
179
+ * about, one level up and structural rather than accidental.
180
+ *
181
+ * So the app supplies it, the same seam `auth.resolveScopes` already offers for
182
+ * this shape. Absent keeps today's behaviour exactly.
183
+ *
184
+ * **It is ASYNC, and the sync-only first version was the defect.** The seam was
185
+ * built for an app whose addressing unit is its own, and an app that has one
186
+ * keeps it in a TABLE — the three examples in the option's own docstring
187
+ * (employee, member, contact) are all rows. So the resolver reads the store,
188
+ * so it returns a promise, so the call written in our docstring did not
189
+ * typecheck for the only readers it was written for. A resolver that happens to
190
+ * be sync still satisfies the type; nothing about that case changed.
191
+ *
192
+ * NOT cached here on purpose. A per-connection cache is the obvious next step
193
+ * and it is the app's to make, not ours: the first call decides the answer for
194
+ * the life of the connection, so a member created a second after connect
195
+ * resolves to the fallback until reconnect. An app that knows its own
196
+ * invalidation can memoise inside the resolver; the framework cannot know it.
197
+ */
198
+ export declare const makeSubjectId: (resolve: NotificationsPluginOptions["resolveSubjectId"]) => (ctx: {
199
+ request: {
200
+ subject: {
201
+ id?: string | null;
202
+ };
203
+ };
204
+ }) => Promise<string>;
205
+
163
206
  export declare const memoryNotificationStore: () => NotificationStore;
164
207
 
165
208
  /** The minute-of-day (0–1439) `at` falls on in the IANA `tz`. Uses `Intl` so no
@@ -200,6 +243,34 @@ export declare interface NotificationServiceShape {
200
243
  export declare const notificationsPlugin: (options?: NotificationsPluginOptions) => VoltroPlugin;
201
244
 
202
245
  export declare interface NotificationsPluginOptions {
246
+ /**
247
+ * Resolve WHO the caller's inbox belongs to, when that is not `subject.id`.
248
+ *
249
+ * The addressing unit is the app's, not the framework's. If yours is an
250
+ * employee, a member or a contact — something that need not have an auth user
251
+ * — return its id here and the whole surface (`inbox`, `unreadCount`,
252
+ * `markRead`, `archive`, preferences, quiet hours) follows.
253
+ *
254
+ * notificationsPlugin({ resolveSubjectId: (ctx) => resolveCallerEmployeeId(ctx) })
255
+ *
256
+ * **May be async, and usually has to be.** An app that HAS its own addressing
257
+ * unit almost always stores it in a table — an employee, a member, a contact
258
+ * are rows, not token claims. If the mapping were in the token there would be
259
+ * no need for this seam at all: `subject.id` would already be the right id.
260
+ * The sync-only signature this shipped with made the call in the line above —
261
+ * our own docstring — fail to typecheck for exactly the apps it was built for.
262
+ *
263
+ * Absent keeps `subject.id`, so nothing changes for an app whose units line
264
+ * up. Returning `undefined` for one caller falls back the same way rather
265
+ * than failing the read.
266
+ */
267
+ readonly resolveSubjectId?: (ctx: {
268
+ readonly request: {
269
+ readonly subject: {
270
+ readonly id?: string | null;
271
+ };
272
+ };
273
+ }) => string | undefined | Promise<string | undefined>;
203
274
  /** Delivery channels. The in-app inbox channel is added automatically unless
204
275
  * you pass your own `inApp` channel. Default `[consoleChannel()]` + in-app. */
205
276
  readonly channels?: ReadonlyArray<Channel>;