@12-apps/notifications 4.7.1 → 4.8.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.
Files changed (41) hide show
  1. package/ADOPTING.md +32 -0
  2. package/dist/{chunk-WHBMPHQE.js → chunk-7234ROOP.js} +8 -4
  3. package/dist/chunk-7234ROOP.js.map +1 -0
  4. package/dist/{chunk-XE7HZVMH.js → chunk-BGI4BQ66.js} +1 -1
  5. package/dist/chunk-BGI4BQ66.js.map +1 -0
  6. package/dist/{chunk-HHMRCMQU.js → chunk-GGAZOLNN.js} +2 -2
  7. package/dist/{chunk-CUZW62JS.js → chunk-KU4FVOFA.js} +2 -2
  8. package/dist/{chunk-MMLV4EZT.js → chunk-M5XTROYM.js} +2 -2
  9. package/dist/{create-api-notifications-B3u6Kx3x.d.ts → create-api-notifications-WaexrZnn.d.ts} +12 -4
  10. package/dist/{create-web-notifications-BHCzaU2y.d.ts → create-web-notifications-DrgqtU4Q.d.ts} +2 -2
  11. package/dist/{generators-B9xt3sRh.d.ts → generators-CH5m8Ucj.d.ts} +1 -1
  12. package/dist/hono/index.d.ts +5 -5
  13. package/dist/hono/index.js +3 -3
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.js +2 -2
  16. package/dist/{jobs-BDZ7aGHV.d.ts → jobs-CcsSA0YR.d.ts} +1 -1
  17. package/dist/manifest/server.d.ts +6 -6
  18. package/dist/manifest/server.js +4 -4
  19. package/dist/manifest/web.d.ts +3 -3
  20. package/dist/manifest/web.js +1 -1
  21. package/dist/{preferences-screen-IOW6Y2H2.js → preferences-screen-V3ZBWP7A.js} +2 -2
  22. package/dist/react/index.d.ts +4 -4
  23. package/dist/react/index.js +1 -1
  24. package/dist/server/index.d.ts +7 -7
  25. package/dist/server/index.js +4 -4
  26. package/dist/{types-CXLAG3UU.d.ts → types-CYJN1xCM.d.ts} +39 -2
  27. package/dist/web-push/index.d.ts +2 -2
  28. package/dist/{web-push-Cs14Wp9u.d.ts → web-push-iER5StBm.d.ts} +1 -1
  29. package/dist/{wire-6dzyfDE7.d.ts → wire-CmRrB3_s.d.ts} +1 -1
  30. package/package.json +2 -2
  31. package/src/index.ts +1 -0
  32. package/src/server/db.ts +12 -1
  33. package/src/server/dispatch.ts +4 -0
  34. package/src/server/router.ts +17 -1
  35. package/src/types.ts +39 -1
  36. package/dist/chunk-WHBMPHQE.js.map +0 -1
  37. package/dist/chunk-XE7HZVMH.js.map +0 -1
  38. /package/dist/{chunk-HHMRCMQU.js.map → chunk-GGAZOLNN.js.map} +0 -0
  39. /package/dist/{chunk-CUZW62JS.js.map → chunk-KU4FVOFA.js.map} +0 -0
  40. /package/dist/{chunk-MMLV4EZT.js.map → chunk-M5XTROYM.js.map} +0 -0
  41. /package/dist/{preferences-screen-IOW6Y2H2.js.map → preferences-screen-V3ZBWP7A.js.map} +0 -0
package/dist/index.js CHANGED
@@ -9,11 +9,11 @@ import {
9
9
  mergeChoices,
10
10
  mergeStoredRow,
11
11
  normalizePhoneE164
12
- } from "./chunk-HHMRCMQU.js";
12
+ } from "./chunk-GGAZOLNN.js";
13
13
  import {
14
14
  NOTIFICATION_CHANNELS,
15
15
  taxonomyOf
16
- } from "./chunk-XE7HZVMH.js";
16
+ } from "./chunk-BGI4BQ66.js";
17
17
  import {
18
18
  messagesOf
19
19
  } from "./chunk-M2TVBVH2.js";
@@ -1,5 +1,5 @@
1
1
  import { WireJobBlueprint } from '@12-apps/wiring';
2
- import { A as ApiNotifications } from './create-api-notifications-B3u6Kx3x.js';
2
+ import { A as ApiNotifications } from './create-api-notifications-WaexrZnn.js';
3
3
 
4
4
  /**
5
5
  * The two background jobs getting a message out actually needs.
@@ -1,11 +1,11 @@
1
1
  import * as _12_apps_wiring from '@12-apps/wiring';
2
2
  import { WireRequest } from '@12-apps/wiring';
3
- import { N as NotificationsServerConfig, A as ApiNotifications, b as NotificationsRoute } from '../create-api-notifications-B3u6Kx3x.js';
4
- import { N as NotificationsJobDeps } from '../jobs-BDZ7aGHV.js';
5
- import '../generators-B9xt3sRh.js';
6
- import '../types-CXLAG3UU.js';
7
- import '../wire-6dzyfDE7.js';
8
- import '../web-push-Cs14Wp9u.js';
3
+ import { N as NotificationsServerConfig, A as ApiNotifications, b as NotificationsRoute } from '../create-api-notifications-WaexrZnn.js';
4
+ import { N as NotificationsJobDeps } from '../jobs-CcsSA0YR.js';
5
+ import '../generators-CH5m8Ucj.js';
6
+ import '../types-CYJN1xCM.js';
7
+ import '../wire-CmRrB3_s.js';
8
+ import '../web-push-iER5StBm.js';
9
9
 
10
10
  /** One `NotificationsRoute` as the wiring contract reads it. */
11
11
  declare function asWireRoute(route: NotificationsRoute): {
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  NOTIFICATIONS_JOBS
3
- } from "../chunk-CUZW62JS.js";
3
+ } from "../chunk-KU4FVOFA.js";
4
4
  import {
5
5
  createApiNotifications
6
- } from "../chunk-WHBMPHQE.js";
7
- import "../chunk-HHMRCMQU.js";
8
- import "../chunk-XE7HZVMH.js";
6
+ } from "../chunk-7234ROOP.js";
7
+ import "../chunk-GGAZOLNN.js";
8
+ import "../chunk-BGI4BQ66.js";
9
9
  import "../chunk-M2TVBVH2.js";
10
10
  import {
11
11
  __name
@@ -1,7 +1,7 @@
1
- import { c as createWebNotifications } from '../create-web-notifications-BHCzaU2y.js';
1
+ import { c as createWebNotifications } from '../create-web-notifications-DrgqtU4Q.js';
2
2
  import 'react';
3
- import '../wire-6dzyfDE7.js';
4
- import '../types-CXLAG3UU.js';
3
+ import '../wire-CmRrB3_s.js';
4
+ import '../types-CYJN1xCM.js';
5
5
 
6
6
  /**
7
7
  * `@12-apps/notifications/manifest/web` — the web capabilities.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createWebNotifications
3
- } from "../chunk-MMLV4EZT.js";
3
+ } from "../chunk-M5XTROYM.js";
4
4
  import "../chunk-BW723CX2.js";
5
5
  import "../chunk-M2TVBVH2.js";
6
6
  import "../chunk-7QVYU63E.js";
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-CQZMTFPY.js";
5
5
  import {
6
6
  NOTIFICATION_CHANNELS
7
- } from "./chunk-XE7HZVMH.js";
7
+ } from "./chunk-BGI4BQ66.js";
8
8
  import {
9
9
  __name
10
10
  } from "./chunk-7QVYU63E.js";
@@ -291,4 +291,4 @@ __name(PreferencesScreen, "PreferencesScreen");
291
291
  export {
292
292
  PreferencesScreen
293
293
  };
294
- //# sourceMappingURL=preferences-screen-IOW6Y2H2.js.map
294
+ //# sourceMappingURL=preferences-screen-V3ZBWP7A.js.map
@@ -1,8 +1,8 @@
1
- import { N as NotificationsApiClient } from '../create-web-notifications-BHCzaU2y.js';
2
- export { B as BADGE_POLL_MS, a as BADGE_RECONCILE_MS, b as BellButtonProps, I as InboxListStatus, d as InboxState, e as InboxStore, f as NotificationsHttpError, g as NotificationsPanelProps, h as NotificationsResult, i as NotificationsSignalHook, j as NotificationsSubscribe, k as NotificationsTransport, l as NotificationsWebConfig, P as PAGE_SIZE, m as PreferencesPayload, n as PreferencesScreenProps, o as PushRegistrationPayload, W as WebNotifications, p as WebPushPlatformHint, q as WebPushSetupConfig, r as createInboxStore, s as createNotificationsApiClient, c as createWebNotifications, t as httpNotificationsTransport, u as useInboxList, v as useInboxState, w as useUnreadCount } from '../create-web-notifications-BHCzaU2y.js';
1
+ import { N as NotificationsApiClient } from '../create-web-notifications-DrgqtU4Q.js';
2
+ export { B as BADGE_POLL_MS, a as BADGE_RECONCILE_MS, b as BellButtonProps, I as InboxListStatus, d as InboxState, e as InboxStore, f as NotificationsHttpError, g as NotificationsPanelProps, h as NotificationsResult, i as NotificationsSignalHook, j as NotificationsSubscribe, k as NotificationsTransport, l as NotificationsWebConfig, P as PAGE_SIZE, m as PreferencesPayload, n as PreferencesScreenProps, o as PushRegistrationPayload, W as WebNotifications, p as WebPushPlatformHint, q as WebPushSetupConfig, r as createInboxStore, s as createNotificationsApiClient, c as createWebNotifications, t as httpNotificationsTransport, u as useInboxList, v as useInboxState, w as useUnreadCount } from '../create-web-notifications-DrgqtU4Q.js';
3
3
  import { JSX } from 'react';
4
- import { N as NotificationMessages } from '../wire-6dzyfDE7.js';
5
- import '../types-CXLAG3UU.js';
4
+ import { N as NotificationMessages } from '../wire-CmRrB3_s.js';
5
+ import '../types-CYJN1xCM.js';
6
6
 
7
7
  /** Inline SVG bell (no icon-library dependency in this package). */
8
8
 
@@ -12,7 +12,7 @@ import {
12
12
  createNotificationsApiClient,
13
13
  createWebNotifications,
14
14
  httpNotificationsTransport
15
- } from "../chunk-MMLV4EZT.js";
15
+ } from "../chunk-M5XTROYM.js";
16
16
  import {
17
17
  BADGE_POLL_MS,
18
18
  BADGE_RECONCILE_MS,
@@ -1,11 +1,11 @@
1
- import { A as ApiNotifications } from '../create-api-notifications-B3u6Kx3x.js';
2
- export { C as CommittedNotification, E as EMAIL_DRIVERS, c as EmailDriver, d as EmailDriverDeclaration, e as EmailMessage, f as ExtraDrivers, L as ListNotificationsInput, g as NotificationAudienceDirectory, h as NotificationChannelPolicy, i as NotificationCommittedListener, j as NotificationContactDirectory, k as NotificationCreateData, l as NotificationDelegate, m as NotificationDeliveryDelegate, n as NotificationDeliveryRow, o as NotificationDeliveryWhere, p as NotificationDispatchScheduler, q as NotificationInboxStore, r as NotificationPageAfter, s as NotificationPreferenceDelegate, t as NotificationPreferenceRow, u as NotificationPreferenceStore, v as NotificationRouter, w as NotificationWhere, a as NotificationsActor, x as NotificationsApiError, y as NotificationsDb, z as NotificationsDbClient, B as NotificationsDbProvider, D as NotificationsRequest, F as NotificationsResponse, b as NotificationsRoute, N as NotificationsServerConfig, G as NotifyByPermission, H as NotifyOptions, I as NotifyResult, P as PermissionNotificationResult, J as PermissionNotificationSkip, K as PushSubscriptionDelegate, M as PushSubscriptionInput, O as PushSubscriptionRow, Q as PushSubscriptionStore, S as SMS_DRIVERS, R as SmsDriver, T as SmsDriverDeclaration, U as SmsMessage, V as TransportDeclaration, W as TransportRegistry, X as WHATSAPP_DRIVERS, Y as WhatsAppDriver, Z as WhatsAppDriverDeclaration, _ as WhatsAppMessage, $ as createApiNotifications, a0 as createTransportRegistry, a1 as emailTransport, a2 as foldApiError, a3 as formatEmail, a4 as formatSms, a5 as formatWhatsApp, a6 as ok, a7 as smsTransport, a8 as whatsAppTransport } from '../create-api-notifications-B3u6Kx3x.js';
3
- export { D as DriverDeclarationBase, F as FetchImpl, N as NotificationProviderError, W as WEB_PUSH_DRIVERS, a as WebPushDriverDeclaration, b as WebPushMessage, c as WebPushSender, d as WebPushSubscription, e as WebPushSubscriptionSource, f as absoluteLink, g as formatWebPush, w as webPushTransport } from '../web-push-Cs14Wp9u.js';
1
+ import { A as ApiNotifications } from '../create-api-notifications-WaexrZnn.js';
2
+ export { C as CommittedNotification, E as EMAIL_DRIVERS, c as EmailDriver, d as EmailDriverDeclaration, e as EmailMessage, f as ExtraDrivers, L as ListNotificationsInput, g as NotificationAudienceDirectory, h as NotificationChannelPolicy, i as NotificationCommittedListener, j as NotificationContactDirectory, k as NotificationCreateData, l as NotificationDelegate, m as NotificationDeliveryDelegate, n as NotificationDeliveryRow, o as NotificationDeliveryWhere, p as NotificationDispatchScheduler, q as NotificationInboxStore, r as NotificationPageAfter, s as NotificationPreferenceDelegate, t as NotificationPreferenceRow, u as NotificationPreferenceStore, v as NotificationRouter, w as NotificationWhere, a as NotificationsActor, x as NotificationsApiError, y as NotificationsDb, z as NotificationsDbClient, B as NotificationsDbProvider, D as NotificationsRequest, F as NotificationsResponse, b as NotificationsRoute, N as NotificationsServerConfig, G as NotifyByPermission, H as NotifyOptions, I as NotifyResult, P as PermissionNotificationResult, J as PermissionNotificationSkip, K as PushSubscriptionDelegate, M as PushSubscriptionInput, O as PushSubscriptionRow, Q as PushSubscriptionStore, S as SMS_DRIVERS, R as SmsDriver, T as SmsDriverDeclaration, U as SmsMessage, V as TransportDeclaration, W as TransportRegistry, X as WHATSAPP_DRIVERS, Y as WhatsAppDriver, Z as WhatsAppDriverDeclaration, _ as WhatsAppMessage, $ as createApiNotifications, a0 as createTransportRegistry, a1 as emailTransport, a2 as foldApiError, a3 as formatEmail, a4 as formatSms, a5 as formatWhatsApp, a6 as ok, a7 as smsTransport, a8 as whatsAppTransport } from '../create-api-notifications-WaexrZnn.js';
3
+ export { D as DriverDeclarationBase, F as FetchImpl, N as NotificationProviderError, W as WEB_PUSH_DRIVERS, a as WebPushDriverDeclaration, b as WebPushMessage, c as WebPushSender, d as WebPushSubscription, e as WebPushSubscriptionSource, f as absoluteLink, g as formatWebPush, w as webPushTransport } from '../web-push-iER5StBm.js';
4
4
  import { NotifyPort } from '@12-apps/wiring/ports';
5
- export { a as NOTIFICATIONS_DRAIN_CRON, b as NOTIFICATIONS_DRAIN_LEASE_MS, c as NOTIFICATIONS_JOBS, d as NOTIFICATIONS_SWEEP_QUEUE, N as NotificationsJobDeps } from '../jobs-BDZ7aGHV.js';
6
- import '../generators-B9xt3sRh.js';
7
- import '../types-CXLAG3UU.js';
8
- import '../wire-6dzyfDE7.js';
5
+ export { a as NOTIFICATIONS_DRAIN_CRON, b as NOTIFICATIONS_DRAIN_LEASE_MS, c as NOTIFICATIONS_JOBS, d as NOTIFICATIONS_SWEEP_QUEUE, N as NotificationsJobDeps } from '../jobs-CcsSA0YR.js';
6
+ import '../generators-CH5m8Ucj.js';
7
+ import '../types-CYJN1xCM.js';
8
+ import '../wire-CmRrB3_s.js';
9
9
  import '@12-apps/wiring';
10
10
 
11
11
  /**
@@ -4,7 +4,7 @@ import {
4
4
  NOTIFICATIONS_JOBS,
5
5
  NOTIFICATIONS_SWEEP_QUEUE,
6
6
  wireNotifyPort
7
- } from "../chunk-CUZW62JS.js";
7
+ } from "../chunk-KU4FVOFA.js";
8
8
  import {
9
9
  EMAIL_DRIVERS,
10
10
  NotificationProviderError,
@@ -25,9 +25,9 @@ import {
25
25
  smsTransport,
26
26
  webPushTransport,
27
27
  whatsAppTransport
28
- } from "../chunk-WHBMPHQE.js";
29
- import "../chunk-HHMRCMQU.js";
30
- import "../chunk-XE7HZVMH.js";
28
+ } from "../chunk-7234ROOP.js";
29
+ import "../chunk-GGAZOLNN.js";
30
+ import "../chunk-BGI4BQ66.js";
31
31
  import "../chunk-M2TVBVH2.js";
32
32
  import "../chunk-7QVYU63E.js";
33
33
  export {
@@ -84,12 +84,44 @@ interface NotificationEvent<TPayload = unknown> {
84
84
  * generator never touches existing generators, the router, or any transport
85
85
  * (open/closed).
86
86
  */
87
+ /**
88
+ * Who the content is being rendered FOR — the reader, at the moment the
89
+ * generator is asked.
90
+ *
91
+ * A notification is stored as rendered TEXT: title and body are columns, so
92
+ * the language is chosen once, when the row is written, and never again. That
93
+ * makes this the only honest place to ask. A generator is registered at BOOT —
94
+ * a host that resolved its words there would pin every future reader to
95
+ * whichever language the process happened to start in, invisibly, because a
96
+ * single-locale host cannot tell the difference.
97
+ *
98
+ * The tag is the RECIPIENT's, never the request's. The person who triggers a
99
+ * notification is routinely not the person who reads it: an invite is sent
100
+ * because an administrator acted and is read by the invitee. Reading
101
+ * `Accept-Language` here would be a bug that only ever surfaces as somebody
102
+ * being told things in a language they do not speak.
103
+ *
104
+ * Absent means "nobody said" — a host with one audience, or one that stores no
105
+ * per-user language, populates nothing and every generator answers with its
106
+ * own default exactly as it did before this existed.
107
+ */
108
+ interface NotificationGenerateContext {
109
+ readonly locale?: string | null;
110
+ }
87
111
  interface NotificationGenerator<TPayload = unknown> {
88
112
  /** The event key, dot-namespaced ("order.paid"). One generator per type. */
89
113
  type: string;
90
114
  /** The preference category the router gates this type's fan-out on. */
91
115
  category: NotificationCategory;
92
- generate: (payload: TPayload) => NotificationContent;
116
+ /**
117
+ * Render this event's content for ONE recipient.
118
+ *
119
+ * `context` is OPTIONAL, and that is what keeps every generator written
120
+ * before it working: a one-parameter `generate` is assignable to this
121
+ * signature unchanged. A host that passes nothing is stating a fact — it has
122
+ * no language for this reader — rather than asserting a default.
123
+ */
124
+ generate: (payload: TPayload, context?: NotificationGenerateContext) => NotificationContent;
93
125
  }
94
126
  /**
95
127
  * The recipient as a transport sees them: resolved destinations only. Built by
@@ -100,6 +132,11 @@ interface NotificationGenerator<TPayload = unknown> {
100
132
  interface TransportRecipient {
101
133
  userId: string;
102
134
  email: string | null;
135
+ /**
136
+ * The recipient's own language, when the host's contact directory states
137
+ * one. Absent means "nobody said" — see {@link NotificationGenerateContext}.
138
+ */
139
+ locale?: string | null;
103
140
  /** Phone as the host stores it (transports normalize per provider rules). */
104
141
  phone: string | null;
105
142
  /** How many active browser push subscriptions the user holds. */
@@ -152,4 +189,4 @@ interface NotificationLogger {
152
189
  error(message: string, ...meta: unknown[]): void;
153
190
  }
154
191
 
155
- export { type DeliveryStatus as D, NOTIFICATION_CHANNELS as N, type TransportRecipient as T, type NotificationCategory as a, type NotificationChannel as b, type NotificationContent as c, type NotificationEvent as d, type NotificationGenerator as e, type NotificationLogger as f, type NotificationRecipient as g, type NotificationTaxonomy as h, type NotificationTransport as i, taxonomyOf as t };
192
+ export { type DeliveryStatus as D, NOTIFICATION_CHANNELS as N, type TransportRecipient as T, type NotificationCategory as a, type NotificationChannel as b, type NotificationContent as c, type NotificationEvent as d, type NotificationGenerateContext as e, type NotificationGenerator as f, type NotificationLogger as g, type NotificationRecipient as h, type NotificationTaxonomy as i, type NotificationTransport as j, taxonomyOf as t };
@@ -1,5 +1,5 @@
1
- import { c as WebPushSender } from '../web-push-Cs14Wp9u.js';
2
- import '../types-CXLAG3UU.js';
1
+ import { c as WebPushSender } from '../web-push-iER5StBm.js';
2
+ import '../types-CYJN1xCM.js';
3
3
 
4
4
  /**
5
5
  * `@12-apps/notifications/web-push` — the VAPID sender, behind its own subpath.
@@ -1,4 +1,4 @@
1
- import { f as NotificationLogger, c as NotificationContent, i as NotificationTransport } from './types-CXLAG3UU.js';
1
+ import { g as NotificationLogger, c as NotificationContent, j as NotificationTransport } from './types-CYJN1xCM.js';
2
2
 
3
3
  /**
4
4
  * The DRIVER port — the reason a host adds a vendor with a config entry and no
@@ -1,4 +1,4 @@
1
- import { a as NotificationCategory, b as NotificationChannel } from './types-CXLAG3UU.js';
1
+ import { a as NotificationCategory, b as NotificationChannel } from './types-CYJN1xCM.js';
2
2
 
3
3
  /**
4
4
  * Every sentence this package can say to a USER, stated by the HOST.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/notifications",
3
- "version": "4.7.1",
3
+ "version": "4.8.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Plug-and-play notification system (12-15): an always-on in-app inbox, per-user × per-category channel preferences, and email / SMS / WhatsApp / web-push transports behind vendor DRIVERS so a second provider is a config entry. Framework-free core (.), host-mounted backend surface (./server: inbox / preferences / push-subscription endpoints, the channel router with delivery records + retry sweep, the permission fan-out, duck-typed Prisma seam), Hono adapter (./hono), React surface (./react: bell + badge, inbox drawer, preferences screen), VAPID sender (./web-push) and the package-owned Prisma partial + migrations. Standardized adoption contract in ADOPTING.md.",
@@ -80,7 +80,7 @@
80
80
  "devDependencies": {
81
81
  "@12-apps/eslint-config": "^1.22.0",
82
82
  "@12-apps/typescript-config": "^1.21.0",
83
- "@12-apps/wiring": "^1.14.0",
83
+ "@12-apps/wiring": "^1.16.0",
84
84
  "@testing-library/react": "^16.1.0",
85
85
  "@types/react": "19.2.2",
86
86
  "@types/react-dom": "19.2.2",
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  type NotificationChannel,
29
29
  type NotificationContent,
30
30
  type NotificationEvent,
31
+ type NotificationGenerateContext,
31
32
  type NotificationGenerator,
32
33
  type NotificationLogger,
33
34
  type NotificationRecipient,
package/src/server/db.ts CHANGED
@@ -246,7 +246,18 @@ export type NotificationsDbProvider = () => Promise<NotificationsDb>;
246
246
  * Returning `null` for a user id means "no such recipient", which `notify`
247
247
  * treats as a caller bug and throws on — a notification addressed to nobody is
248
248
  * never silently dropped.
249
+ *
250
+ * `locale` is the recipient's own language, and it is here rather than on the
251
+ * event because it is a fact about the PERSON, not about what happened
252
+ * The host owns it for the same reason it owns the address: this
253
+ * package has no user table to read it from. Absent — the field omitted, or
254
+ * `null` for a host that stores no language yet — means "nobody said", and
255
+ * every generator answers with its default exactly as before.
249
256
  */
250
257
  export interface NotificationContactDirectory {
251
- getContact(userId: string): Promise<{ email: string | null; phone: string | null } | null>;
258
+ getContact(userId: string): Promise<{
259
+ email: string | null;
260
+ phone: string | null;
261
+ locale?: string | null;
262
+ } | null>;
252
263
  }
@@ -99,6 +99,10 @@ export async function loadRecipient(
99
99
  userId,
100
100
  email: contact.email,
101
101
  phone: contact.phone,
102
+ // Carried through UNRESOLVED and only where the host supplied one: the
103
+ // absent case has to stay distinguishable from a stated language, because
104
+ // that is what lets a generator apply its own default in one place.
105
+ ...(contact.locale === undefined ? {} : { locale: contact.locale }),
102
106
  pushSubscriptionCount: await deps.pushSubscriptions.count(userId),
103
107
  };
104
108
  }
@@ -235,11 +235,27 @@ export function createNotificationRouter(deps: NotificationRouterDeps): Notifica
235
235
 
236
236
  async notify(event, options = {}) {
237
237
  const generator = deps.generators.resolve(event.type);
238
- const content = generator.generate(event.payload as never);
239
238
 
239
+ /*
240
+ The recipient is loaded BEFORE the content is rendered, and the order is
241
+ the whole of the fix.
242
+
243
+ `notify` is already per-person — `event.recipient.userId` is one user,
244
+ and a permission fan-out resolves its audience and calls this once each
245
+ — so the reader was knowable here all along. Rendering first simply
246
+ threw that away: the row was written in whatever language the generator
247
+ had been bound to at boot, for everyone.
248
+
249
+ Loading first also means a notification addressed to nobody now throws
250
+ before any content is built, which is the cheaper order anyway.
251
+ */
240
252
  const recipient = await loadRecipient(deps, event.recipient.userId);
241
253
  if (!recipient) throw new UnknownNotificationRecipientError(event.recipient.userId);
242
254
 
255
+ // Forwarded exactly as the directory stated it, `undefined` included: the
256
+ // generator owns the fallback, in one place a reader can find.
257
+ const content = generator.generate(event.payload as never, { locale: recipient.locale });
258
+
243
259
  const channels = await resolveChannels(deps, event, generator.category, recipient);
244
260
  const notification = await commit(deps, event, generator.category, content, channels);
245
261
 
package/src/types.ts CHANGED
@@ -91,12 +91,45 @@ export interface NotificationEvent<TPayload = unknown> {
91
91
  * generator never touches existing generators, the router, or any transport
92
92
  * (open/closed).
93
93
  */
94
+ /**
95
+ * Who the content is being rendered FOR — the reader, at the moment the
96
+ * generator is asked.
97
+ *
98
+ * A notification is stored as rendered TEXT: title and body are columns, so
99
+ * the language is chosen once, when the row is written, and never again. That
100
+ * makes this the only honest place to ask. A generator is registered at BOOT —
101
+ * a host that resolved its words there would pin every future reader to
102
+ * whichever language the process happened to start in, invisibly, because a
103
+ * single-locale host cannot tell the difference.
104
+ *
105
+ * The tag is the RECIPIENT's, never the request's. The person who triggers a
106
+ * notification is routinely not the person who reads it: an invite is sent
107
+ * because an administrator acted and is read by the invitee. Reading
108
+ * `Accept-Language` here would be a bug that only ever surfaces as somebody
109
+ * being told things in a language they do not speak.
110
+ *
111
+ * Absent means "nobody said" — a host with one audience, or one that stores no
112
+ * per-user language, populates nothing and every generator answers with its
113
+ * own default exactly as it did before this existed.
114
+ */
115
+ export interface NotificationGenerateContext {
116
+ readonly locale?: string | null;
117
+ }
118
+
94
119
  export interface NotificationGenerator<TPayload = unknown> {
95
120
  /** The event key, dot-namespaced ("order.paid"). One generator per type. */
96
121
  type: string;
97
122
  /** The preference category the router gates this type's fan-out on. */
98
123
  category: NotificationCategory;
99
- generate: (payload: TPayload) => NotificationContent;
124
+ /**
125
+ * Render this event's content for ONE recipient.
126
+ *
127
+ * `context` is OPTIONAL, and that is what keeps every generator written
128
+ * before it working: a one-parameter `generate` is assignable to this
129
+ * signature unchanged. A host that passes nothing is stating a fact — it has
130
+ * no language for this reader — rather than asserting a default.
131
+ */
132
+ generate: (payload: TPayload, context?: NotificationGenerateContext) => NotificationContent;
100
133
  }
101
134
 
102
135
  /**
@@ -108,6 +141,11 @@ export interface NotificationGenerator<TPayload = unknown> {
108
141
  export interface TransportRecipient {
109
142
  userId: string;
110
143
  email: string | null;
144
+ /**
145
+ * The recipient's own language, when the host's contact directory states
146
+ * one. Absent means "nobody said" — see {@link NotificationGenerateContext}.
147
+ */
148
+ locale?: string | null;
111
149
  /** Phone as the host stores it (transports normalize per provider rules). */
112
150
  phone: string | null;
113
151
  /** How many active browser push subscriptions the user holds. */