@12-apps/notifications 4.11.0 → 4.13.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 (51) hide show
  1. package/dist/{chunk-2TJ4D2KE.js → chunk-4DTUD74E.js} +2 -2
  2. package/dist/{chunk-WVRODNXQ.js → chunk-53SH5ABN.js} +111 -46
  3. package/dist/chunk-53SH5ABN.js.map +1 -0
  4. package/dist/{chunk-6W7INOYQ.js → chunk-CSMFJJXY.js} +1 -1
  5. package/dist/chunk-CSMFJJXY.js.map +1 -0
  6. package/dist/{chunk-ZIR3ILFH.js → chunk-QPO6NSRR.js} +2 -2
  7. package/dist/{chunk-SWOWHIFE.js → chunk-XFODKRRB.js} +37 -2
  8. package/dist/chunk-XFODKRRB.js.map +1 -0
  9. package/dist/{create-api-notifications-CcPYrM3p.d.ts → create-api-notifications-cIEbFqoZ.d.ts} +132 -32
  10. package/dist/{create-web-notifications-CnaXx6km.d.ts → create-web-notifications-BODiQRK-.d.ts} +2 -2
  11. package/dist/{generators-qAD4fNPq.d.ts → generators-GUF-Kml-.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 +9 -3
  16. package/dist/{jobs--fex87-q.d.ts → jobs-wKoOz91Q.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-SXUIGECY.js → preferences-screen-YRPNUZS5.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-BlqZkCWZ.d.ts → types-DPiePHJD.d.ts} +52 -1
  27. package/dist/web-push/index.d.ts +2 -2
  28. package/dist/{web-push-Dnyaha2z.d.ts → web-push-DUn_d_gj.d.ts} +12 -2
  29. package/dist/{wire-BG1kuoXX.d.ts → wire-CJka1AvM.d.ts} +65 -2
  30. package/package.json +2 -2
  31. package/prisma/migrations/20260914210000_scope_push_subscriptions_per_store/migration.sql +31 -0
  32. package/prisma/notifications.prisma +12 -0
  33. package/src/index.ts +4 -0
  34. package/src/preferences-core.ts +107 -0
  35. package/src/server/context.ts +15 -0
  36. package/src/server/db.ts +50 -8
  37. package/src/server/dispatch.ts +9 -2
  38. package/src/server/inbox.ts +87 -18
  39. package/src/server/index.ts +2 -0
  40. package/src/server/preferences.ts +29 -9
  41. package/src/server/push-subscriptions.ts +61 -7
  42. package/src/server/router.ts +42 -8
  43. package/src/server/routes.ts +14 -5
  44. package/src/server/transports/web-push.ts +15 -2
  45. package/src/types.ts +52 -1
  46. package/dist/chunk-6W7INOYQ.js.map +0 -1
  47. package/dist/chunk-SWOWHIFE.js.map +0 -1
  48. package/dist/chunk-WVRODNXQ.js.map +0 -1
  49. /package/dist/{chunk-2TJ4D2KE.js.map → chunk-4DTUD74E.js.map} +0 -0
  50. /package/dist/{chunk-ZIR3ILFH.js.map → chunk-QPO6NSRR.js.map} +0 -0
  51. /package/dist/{preferences-screen-SXUIGECY.js.map → preferences-screen-YRPNUZS5.js.map} +0 -0
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  DEFAULT_CHANNEL_ROW,
3
- enabledChannelsOf,
4
- mergeChoices,
3
+ explicitChoicesOf,
5
4
  mergeStoredRow,
5
+ resolveTypeChannels,
6
6
  type ChannelMatrix,
7
7
  type ChannelRow,
8
+ type TypeChannelRules,
8
9
  } from '../preferences-core';
9
10
  import type {
10
11
  NotificationCategory,
@@ -32,10 +33,20 @@ export interface NotificationPreferenceStore {
32
33
  userId: string,
33
34
  input: Partial<Record<NotificationCategory, Partial<ChannelRow>>>,
34
35
  ): Promise<void>;
35
- /** The channels enabled for one (user, category) — the router's gate. */
36
+ /**
37
+ * The channels enabled for one (user, category) — the router's gate.
38
+ *
39
+ * `rules` carries the emitting TYPE's own declarations (availability and
40
+ * per-type defaults). It is OPTIONAL, and that is what keeps a host store
41
+ * written before it working: a two-parameter implementation is assignable to
42
+ * this signature unchanged, and omitting the argument asks the same question
43
+ * the store has always answered. The router does not rely on a store
44
+ * honouring it — it caps the result by availability itself.
45
+ */
36
46
  enabledChannels(
37
47
  userId: string,
38
48
  category: NotificationCategory,
49
+ rules?: TypeChannelRules,
39
50
  ): Promise<NotificationChannel[]>;
40
51
  }
41
52
 
@@ -72,6 +83,14 @@ export function createPreferenceStore(
72
83
  * A category outside the taxonomy is IGNORED rather than stored: the DB
73
84
  * CHECK would reject it anyway, and a 500 from a stale client's extra key
74
85
  * would fail the whole save including the toggle the user did flip.
86
+ *
87
+ * What is written is the user's EXPLICIT choices only, never their
88
+ * effective row. Merging onto the effective row wrote a boolean for all
89
+ * four channels the moment anyone touched any switch, so the row could
90
+ * never again say "no opinion" about a channel — which silently disabled
91
+ * every per-type and per-host default for that user, and defeated the
92
+ * missing-key fallback that lets a new channel ship without a data
93
+ * migration.
75
94
  */
76
95
  async save(userId, input) {
77
96
  const client = await db();
@@ -80,8 +99,7 @@ export function createPreferenceStore(
80
99
  const existing = await client.notificationPreference.findUnique({
81
100
  where: { userId_category: { userId, category } },
82
101
  });
83
- const current = existing ? mergeStoredRow(existing.channels, defaultRow) : defaultRow;
84
- const channels = mergeChoices(current, choices);
102
+ const channels = { ...explicitChoicesOf(existing?.channels), ...choices };
85
103
  await client.notificationPreference.upsert({
86
104
  where: { userId_category: { userId, category } },
87
105
  create: { userId, category, channels },
@@ -90,14 +108,16 @@ export function createPreferenceStore(
90
108
  }
91
109
  },
92
110
 
93
- async enabledChannels(userId, category) {
111
+ async enabledChannels(userId, category, rules) {
94
112
  const client = await db();
95
113
  const row = await client.notificationPreference.findUnique({
96
114
  where: { userId_category: { userId, category } },
97
115
  });
98
- return enabledChannelsOf(
99
- row ? mergeStoredRow(row.channels, defaultRow) : defaultRow,
100
- );
116
+ return resolveTypeChannels({
117
+ stored: row?.channels,
118
+ categoryDefaults: defaultRow,
119
+ rules,
120
+ });
101
121
  },
102
122
  };
103
123
  }
@@ -1,6 +1,6 @@
1
1
  import type { NotificationLogger } from '../types';
2
2
 
3
- import type { NotificationsDbProvider } from './db';
3
+ import type { NotificationsDbProvider, PushSubscriptionWhere } from './db';
4
4
  import type { WebPushSubscriptionSource } from './transports/web-push';
5
5
 
6
6
  /**
@@ -16,6 +16,41 @@ export interface PushSubscriptionInput {
16
16
  keys: { p256dh: string; auth: string };
17
17
  /** Optional browser/device hint for a device list. */
18
18
  userAgent?: string;
19
+ /**
20
+ * Which ORIGIN this browser subscribed on — the HOST's resolved
21
+ * value, never anything the caller sent. Absent/null = the platform origin.
22
+ */
23
+ clientId?: string | null;
24
+ }
25
+
26
+ /**
27
+ * Which of a user's subscriptions one notification may reach.
28
+ *
29
+ * Stated as a rule on the SUBSCRIPTION rather than as a fallback, because the
30
+ * fallback form ("the platform's rows only when the store's are absent")
31
+ * silently changes the platform origin's behaviour, and a marketplace host must
32
+ * be unaffected by this change:
33
+ *
34
+ * | the subscription was registered on | it receives |
35
+ * |---|---|
36
+ * | the PLATFORM origin (`client_id IS NULL`) | every notification — as today |
37
+ * | store X's origin | store X's, and platform-wide ones (`clientId IS NULL`) |
38
+ *
39
+ * Nothing ever reaches a subscription registered on a DIFFERENT store's origin.
40
+ * It follows without a special case that a store-B notification reaches a
41
+ * customer who installed only store A through their PLATFORM subscription, and
42
+ * through neither app.
43
+ *
44
+ * `notificationClientId` is the NOTIFICATION's tenant. `undefined` (nobody
45
+ * asked to narrow) and `null` (a platform-wide notification) both mean every
46
+ * row, which is why they share a branch.
47
+ */
48
+ function reachableBy(
49
+ userId: string,
50
+ notificationClientId?: string | null,
51
+ ): PushSubscriptionWhere {
52
+ if (notificationClientId === undefined || notificationClientId === null) return { userId };
53
+ return { userId, OR: [{ clientId: null }, { clientId: notificationClientId }] };
19
54
  }
20
55
 
21
56
  export interface PushSubscriptionStore extends WebPushSubscriptionSource {
@@ -34,8 +69,19 @@ export interface PushSubscriptionStore extends WebPushSubscriptionSource {
34
69
  save(userId: string, input: PushSubscriptionInput): Promise<void>;
35
70
  /** Remove one browser's subscription (owner-scoped; unknown = no-op). */
36
71
  remove(userId: string, endpoint: string): Promise<void>;
37
- /** How many devices the user has registered (settings UI hint). */
38
- count(userId: string): Promise<number>;
72
+ /**
73
+ * How many devices the user has registered.
74
+ *
75
+ * TWO callers with different questions. The settings screen asks unscoped —
76
+ * "you have 2 devices" is a fact about the PERSON — and passes nothing. The
77
+ * dispatch path passes the notification's tenant, because this is what
78
+ * `supports()` gates on: left unscoped there, the router would enqueue a
79
+ * WEB_PUSH delivery for a notification no reachable subscription exists for,
80
+ * `send` would find an empty list and throw, and the sweep would burn every
81
+ * attempt before writing DEAD — a FAILED row for something that was never
82
+ * undeliverable, only out of scope.
83
+ */
84
+ count(userId: string, notificationClientId?: string | null): Promise<number>;
39
85
  /**
40
86
  * Whether THIS endpoint is currently registered to THIS user.
41
87
  *
@@ -75,12 +121,18 @@ export function createPushSubscriptionStore(
75
121
  endpoint: input.endpoint,
76
122
  p256dh: input.keys.p256dh,
77
123
  auth: input.keys.auth,
124
+ clientId: input.clientId ?? null,
78
125
  userAgent: input.userAgent ?? null,
79
126
  },
127
+ // Re-stamped on every save, so the same browser moving between a store's
128
+ // app and the platform corrects its own scope rather than keeping the
129
+ // first origin it ever subscribed from. A scope change on the SAME user
130
+ // is not a re-own and must not log one.
80
131
  update: {
81
132
  userId,
82
133
  p256dh: input.keys.p256dh,
83
134
  auth: input.keys.auth,
135
+ clientId: input.clientId ?? null,
84
136
  userAgent: input.userAgent ?? null,
85
137
  },
86
138
  });
@@ -91,9 +143,9 @@ export function createPushSubscriptionStore(
91
143
  await client.pushSubscription.deleteMany({ where: { userId, endpoint } });
92
144
  },
93
145
 
94
- async count(userId) {
146
+ async count(userId, notificationClientId) {
95
147
  const client = await db();
96
- return client.pushSubscription.count({ where: { userId } });
148
+ return client.pushSubscription.count({ where: reachableBy(userId, notificationClientId) });
97
149
  },
98
150
 
99
151
  async isRegisteredTo(userId, endpoint) {
@@ -102,9 +154,11 @@ export function createPushSubscriptionStore(
102
154
  return row?.userId === userId;
103
155
  },
104
156
 
105
- async list(userId) {
157
+ async list(userId, notificationClientId) {
106
158
  const client = await db();
107
- const rows = await client.pushSubscription.findMany({ where: { userId } });
159
+ const rows = await client.pushSubscription.findMany({
160
+ where: reachableBy(userId, notificationClientId),
161
+ });
108
162
  return rows.map((row) => ({
109
163
  id: row.id,
110
164
  endpoint: row.endpoint,
@@ -1,7 +1,12 @@
1
1
  import { UnknownNotificationRecipientError } from '../errors';
2
2
  import type { NotificationGeneratorRegistry } from '../generators';
3
- import { DEFAULT_CHANNEL_ROW, enabledChannelsOf } from '../preferences-core';
4
- import type { NotificationChannel, NotificationEvent, TransportRecipient } from '../types';
3
+ import { capToAvailable, DEFAULT_CHANNEL_ROW, enabledChannelsOf } from '../preferences-core';
4
+ import type {
5
+ NotificationChannel,
6
+ NotificationEvent,
7
+ NotificationGenerator,
8
+ TransportRecipient,
9
+ } from '../types';
5
10
 
6
11
  import {
7
12
  dispatchOne,
@@ -174,14 +179,38 @@ function announce(deps: NotificationRouterDeps, notification: CommittedNotificat
174
179
  }
175
180
  }
176
181
 
177
- /** The channels one emit will actually enqueue: preference ∩ transport ∩ plan. */
182
+ /**
183
+ * The channels one emit will actually enqueue:
184
+ * preference ∩ transport ∩ plan ∩ the TYPE's own availability.
185
+ *
186
+ * Availability is applied LAST rather than left to the preference store,
187
+ * because {@link NotificationChannelPolicy} is a HOST function returning an
188
+ * array and nothing constrains it to a subset of what it was handed. A policy
189
+ * that returns a channel it was not given — a host reading the plan's own
190
+ * entitlement list rather than filtering the argument — would otherwise put
191
+ * back a channel this type had just declared it does not offer. The final
192
+ * filter makes availability independent of what any host code returns.
193
+ *
194
+ * {@link policyFallback}, by contrast, cannot reintroduce anything: it filters
195
+ * its own already-capped input. It is not a reason for this filter, and the
196
+ * cost of the filter is one pass over at most four strings.
197
+ *
198
+ * The inbox record is NOT gated by any of this — `commit` writes it whatever
199
+ * this returns, including the empty array — because the inbox is the record of
200
+ * what happened rather than a channel a user opts out of.
201
+ */
178
202
  async function resolveChannels(
179
203
  deps: NotificationRouterDeps,
180
204
  event: NotificationEvent<unknown>,
181
- category: string,
205
+ generator: Pick<NotificationGenerator<never>, 'category' | 'channels' | 'channelDefaults'>,
182
206
  recipient: TransportRecipient,
183
207
  ): Promise<NotificationChannel[]> {
184
- const enabled = await deps.preferences.enabledChannels(event.recipient.userId, category);
208
+ const rules = { channels: generator.channels, channelDefaults: generator.channelDefaults };
209
+ const enabled = await deps.preferences.enabledChannels(
210
+ event.recipient.userId,
211
+ generator.category,
212
+ rules,
213
+ );
185
214
  const supported = enabled.filter((channel) => {
186
215
  const transport = deps.transports.get(channel);
187
216
  return transport !== null && transport.supports(recipient);
@@ -189,7 +218,8 @@ async function resolveChannels(
189
218
  // The host's plan gate: a tenant-scoped emit keeps only the channels the
190
219
  // tenant's plan covers, so a revoked transport DEGRADES to the remaining ones
191
220
  // rather than dropping the notification silently.
192
- return applyPolicy(deps, event.recipient.clientId, supported);
221
+ const permitted = await applyPolicy(deps, event.recipient.clientId, supported);
222
+ return capToAvailable(permitted, generator.channels);
193
223
  }
194
224
 
195
225
  /** Commit the inbox record and its delivery rows together, in one transaction. */
@@ -249,14 +279,18 @@ export function createNotificationRouter(deps: NotificationRouterDeps): Notifica
249
279
  Loading first also means a notification addressed to nobody now throws
250
280
  before any content is built, which is the cheaper order anyway.
251
281
  */
252
- const recipient = await loadRecipient(deps, event.recipient.userId);
282
+ const recipient = await loadRecipient(
283
+ deps,
284
+ event.recipient.userId,
285
+ event.recipient.clientId ?? null,
286
+ );
253
287
  if (!recipient) throw new UnknownNotificationRecipientError(event.recipient.userId);
254
288
 
255
289
  // Forwarded exactly as the directory stated it, `undefined` included: the
256
290
  // generator owns the fallback, in one place a reader can find.
257
291
  const content = generator.generate(event.payload as never, { locale: recipient.locale });
258
292
 
259
- const channels = await resolveChannels(deps, event, generator.category, recipient);
293
+ const channels = await resolveChannels(deps, event, generator, recipient);
260
294
  const notification = await commit(deps, event, generator.category, content, channels);
261
295
 
262
296
  // AFTER the transaction, never inside it: a subscriber woken by an event
@@ -66,12 +66,14 @@ interface NotificationRoutesDeps {
66
66
  async function channelAvailability(
67
67
  deps: NotificationRoutesDeps,
68
68
  userId: string,
69
+ scopeClientId: string | null,
69
70
  ): Promise<Record<NotificationChannel, boolean>> {
70
71
  const contact = await deps.contacts.getContact(userId);
71
72
  const recipient = {
72
73
  userId,
73
74
  email: contact?.email ?? null,
74
75
  phone: contact?.phone ?? null,
76
+ clientId: scopeClientId,
75
77
  pushSubscriptionCount: 1,
76
78
  };
77
79
  return Object.fromEntries(
@@ -86,6 +88,7 @@ async function channelAvailability(
86
88
  async function preferencesPayload(
87
89
  deps: NotificationRoutesDeps,
88
90
  userId: string,
91
+ scopeClientId: string | null,
89
92
  ): Promise<{
90
93
  preferences: Record<string, Record<string, boolean>>;
91
94
  availability: Record<NotificationChannel, boolean>;
@@ -93,7 +96,7 @@ async function preferencesPayload(
93
96
  }> {
94
97
  const [preferences, availability] = await Promise.all([
95
98
  deps.preferences.get(userId),
96
- channelAvailability(deps, userId),
99
+ channelAvailability(deps, userId, scopeClientId),
97
100
  ]);
98
101
  // `categories` travels with the matrix so the settings screen renders the
99
102
  // HOST's taxonomy without being told it twice (once in the api config, once
@@ -111,6 +114,7 @@ function inboxRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
111
114
  await deps.inbox.list(
112
115
  actor.userId,
113
116
  parseListQuery(query, messagesOf(deps, locale)),
117
+ actor.scopeClientId,
114
118
  ),
115
119
  ),
116
120
  ),
@@ -120,7 +124,7 @@ function inboxRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
120
124
  path: '/notifications/unread-count',
121
125
  handle: guarded(async ({ actor }) =>
122
126
  // Polled by the SPAs, so it stays a single indexed COUNT.
123
- ok({ count: await deps.inbox.unreadCount(actor.userId) }),
127
+ ok({ count: await deps.inbox.unreadCount(actor.userId, actor.scopeClientId) }),
124
128
  ),
125
129
  },
126
130
  {
@@ -130,7 +134,7 @@ function inboxRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
130
134
  const target = parseMarkReadBody(body, messagesOf(deps, locale));
131
135
  const updated =
132
136
  'all' in target
133
- ? await deps.inbox.markAllRead(actor.userId)
137
+ ? await deps.inbox.markAllRead(actor.userId, actor.scopeClientId)
134
138
  : await deps.inbox.markRead(actor.userId, target.ids);
135
139
  // Only when something actually flipped. This endpoint is idempotent, so
136
140
  // a re-send of an already-read id reports `updated: 0` and has changed
@@ -163,7 +167,9 @@ function preferenceRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
163
167
  {
164
168
  method: 'GET',
165
169
  path: '/notification-preferences',
166
- handle: guarded(async ({ actor }) => ok(await preferencesPayload(deps, actor.userId))),
170
+ handle: guarded(async ({ actor }) =>
171
+ ok(await preferencesPayload(deps, actor.userId, actor.scopeClientId ?? null)),
172
+ ),
167
173
  },
168
174
  {
169
175
  method: 'PUT',
@@ -175,7 +181,7 @@ function preferenceRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
175
181
  actor.userId,
176
182
  parsePreferencesBody(body, messagesOf(deps, locale)),
177
183
  );
178
- return ok(await preferencesPayload(deps, actor.userId));
184
+ return ok(await preferencesPayload(deps, actor.userId, actor.scopeClientId ?? null));
179
185
  }),
180
186
  },
181
187
  ];
@@ -208,6 +214,9 @@ function pushRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
208
214
  const input = parsePushSubscriptionBody(body, messagesOf(deps, locale));
209
215
  const userAgent = headers?.['user-agent'];
210
216
  await deps.pushSubscriptions.save(actor.userId, {
217
+ // The HOST's resolved origin, never the body's — a caller cannot
218
+ // choose which store's app their browser counts as.
219
+ clientId: actor.scopeClientId ?? null,
211
220
  ...input,
212
221
  ...(userAgent ? { userAgent } : {}),
213
222
  });
@@ -90,7 +90,20 @@ export interface WebPushDriverDeclaration extends DriverDeclarationBase {
90
90
 
91
91
  /** The subscriptions the transport reads and prunes (db-backed by the mount). */
92
92
  export interface WebPushSubscriptionSource {
93
- list(userId: string): Promise<{ id: string; endpoint: string; p256dh: string; auth: string }[]>;
93
+ /**
94
+ * The subscriptions one notification may reach.
95
+ *
96
+ * `notificationClientId` is the notification's tenant — `null` for a
97
+ * platform-wide one, which every subscription receives. An ADOPTER with a
98
+ * hand-written source must honour it: a function declared with fewer
99
+ * parameters still type-checks, so an un-updated implementation silently
100
+ * ignores the scope and keeps fanning out to every origin, with nothing
101
+ * failing to compile to say so.
102
+ */
103
+ list(
104
+ userId: string,
105
+ notificationClientId?: string | null,
106
+ ): Promise<{ id: string; endpoint: string; p256dh: string; auth: string }[]>;
94
107
  prune(id: string): Promise<void>;
95
108
  }
96
109
 
@@ -159,7 +172,7 @@ export function webPushTransport(
159
172
  supports: (recipient: TransportRecipient) => recipient.pushSubscriptionCount > 0,
160
173
  format: formatWebPush,
161
174
  async send(message, recipient) {
162
- const rows = await subscriptions.list(recipient.userId);
175
+ const rows = await subscriptions.list(recipient.userId, recipient.clientId);
163
176
  if (rows.length === 0) throw new Error('Recipient no longer has push subscriptions.');
164
177
  const payload = JSON.stringify(message);
165
178
  let delivered = 0;
package/src/types.ts CHANGED
@@ -130,6 +130,39 @@ export interface NotificationGenerator<TPayload = unknown> {
130
130
  type: string;
131
131
  /** The preference category the router gates this type's fan-out on. */
132
132
  category: NotificationCategory;
133
+ /**
134
+ * The channels this type may EVER use — AVAILABILITY, not preference.
135
+ *
136
+ * Absent (the default) means every channel, which is what every generator
137
+ * written before this field keeps doing. A declared list is a hard cap the
138
+ * router applies AFTER preferences: a user whose stored row explicitly
139
+ * enables a channel this type does not offer still does not get it, because
140
+ * the channel was never on offer for this message. That is the difference
141
+ * from {@link channelDefaults} — a default is a starting point a user can
142
+ * move, availability is the set of starting points that exist.
143
+ *
144
+ * It is per TYPE because the category is too coarse to say it: the three
145
+ * comanda kitchen messages sit in `orders` next to `order.paid`, and a diner
146
+ * three metres from the food wants a push, not correspondence, while the
147
+ * buyer of a delivery order still wants the e-mail. Declaring `["WEB_PUSH"]`
148
+ * on the mesa messages says that without splitting the category or taking
149
+ * `order.paid`'s e-mail away with it.
150
+ *
151
+ * Unknown entries are ignored and order is irrelevant — the list is coerced
152
+ * onto {@link NOTIFICATION_CHANNELS}. An EMPTY list is legal and means no
153
+ * transport channel at all; the inbox record is written regardless, because
154
+ * the inbox is not a channel a user opts out of.
155
+ */
156
+ channels?: readonly NotificationChannel[];
157
+ /**
158
+ * This type's starting toggles, overriding the category's defaults for the
159
+ * channels it names and only where the user has made NO explicit choice.
160
+ *
161
+ * Stored preferences still win over it — that is what makes it a default.
162
+ * To take a channel away from a user who asked for it, declare
163
+ * {@link channels} instead.
164
+ */
165
+ channelDefaults?: Partial<Record<NotificationChannel, boolean>>;
133
166
  /**
134
167
  * Render this event's content for ONE recipient.
135
168
  *
@@ -157,8 +190,26 @@ export interface TransportRecipient {
157
190
  locale?: string | null;
158
191
  /** Phone as the host stores it (transports normalize per provider rules). */
159
192
  phone: string | null;
160
- /** How many active browser push subscriptions the user holds. */
193
+ /**
194
+ * How many push subscriptions this NOTIFICATION can actually reach — not how
195
+ * many the user holds. Scoped by {@link TransportRecipient.clientId}.
196
+ */
161
197
  pushSubscriptionCount: number;
198
+ /**
199
+ * The store this notification belongs to, or `null` for a platform-wide one
200
+ *.
201
+ *
202
+ * REQUIRED, and deliberately so where optional is the tempting answer. An
203
+ * optional field fails OPEN: a construction site that forgets it yields
204
+ * `undefined`, which the push scope reads as "platform notification -> every
205
+ * subscription" — precisely the cross-store fan-out this exists to stop.
206
+ * Three construction sites is the argument FOR requiring it, not against; the
207
+ * compiler then names them.
208
+ *
209
+ * Not a breaking change for adopters: every external use CONSUMES a recipient
210
+ * through `supports`/`send` below, and the only constructors are in-package.
211
+ */
212
+ clientId: string | null;
162
213
  }
163
214
 
164
215
  /**
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["/**\n * Core types of the channel-agnostic notification system (12-15).\n *\n * Three decoupled layers, each open for extension without touching the others:\n * - GENERATORS map a typed domain event to agnostic content (title/body/…).\n * - The CHANNEL ROUTER always writes the notification-centre inbox record,\n * then fans out one delivery per enabled channel.\n * - TRANSPORTS format the agnostic content for one channel and send it.\n *\n * Nothing here knows about a concrete channel's wire format — that lives\n * entirely inside each transport adapter — and nothing here knows about a\n * concrete DOMAIN either: the event `type` set, the preference categories and\n * the channel list are all host config (see {@link NotificationTaxonomy}).\n */\n\n/** Transport channels a notification can fan out to (DB CHECK mirrors this). */\nexport const NOTIFICATION_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'WEB_PUSH'] as const;\nexport type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];\n\n/**\n * The preference categories are the HOST's, and required.\n *\n * There used to be a `NOTIFICATION_CATEGORIES = ['orders','payments','stock',\n * 'system']` here — one product's set — and `taxonomyOf` fell back to it\n * whenever a host passed none. The docstring argued the case itself: \"it is\n * product vocabulary, not machinery\", and then shipped the vocabulary anyway as\n * the default, which is the only part a forgetful host would ever see.\n *\n * The consequence was quiet rather than loud: the settings screen renders four\n * rows a foreign host never chose, its own categories are absent, and every\n * preference a user sets is filed against a taxonomy nothing else in that\n * system uses. Nothing throws, because `category` is deliberately a free string\n * — the packaged migration puts **no CHECK** on it, precisely because a closed\n * set would be wrong for every host but the first. That freedom is what made\n * the default undetectable.\n *\n * `channel` and `status` are different and keep their CHECKs: those ARE this\n * library's own closed sets.\n */\nexport type NotificationCategory = string;\n\n/**\n * Per-channel delivery lifecycle (DB CHECK mirrors this).\n *\n * `SENDING` is the CLAIM: exactly one dispatcher moves a row out of `QUEUED`,\n * so two dispatchers can never both send the same delivery. A row left\n * `SENDING` is a dispatcher that died mid-send, and the sweep reclaims it once\n * it is older than the cutoff.\n *\n * `DEAD` is terminal: the attempt ceiling was reached (or the recipient no\n * longer exists), and no sweep will pick the row up again. Without it a\n * permanently invalid destination is a billed provider call on every sweep,\n * forever, and the sweep's working set only grows.\n */\nexport type DeliveryStatus = 'QUEUED' | 'SENDING' | 'SENT' | 'FAILED' | 'DEAD';\n\n/**\n * Channel-agnostic content a generator produces. This is what the inbox stores\n * verbatim and what every transport's formatter receives — no channel may leak\n * its wire format into it.\n */\nexport interface NotificationContent {\n title: string;\n body: string;\n /** In-app deep link (a same-origin path such as `/orders/123`). */\n link?: string;\n /**\n * Structured extras for consumers that want more than text.\n *\n * ONE KEY IS RESERVED: `liveSubject` (`LIVE_SUBJECT_KEY` in `./live`) ties\n * this notification to a live activity, and the WEB_PUSH transport turns it\n * into the tray `tag` that collapses every push about one subject onto a\n * single entry. A host already using that name for something else acquires\n * that behaviour without asking for it — so it is named here, on the field a\n * generator actually writes, and not only where the feature is documented.\n */\n data?: Record<string, unknown>;\n}\n\n/** Who receives a notification. `clientId` scopes it to a tenant when set. */\nexport interface NotificationRecipient {\n userId: string;\n clientId?: string;\n}\n\n/**\n * A typed domain event handed to `notify`. `type` selects the registered\n * generator; `payload` is that generator's typed input. Callers never touch\n * channels, formatting, or preferences.\n */\nexport interface NotificationEvent<TPayload = unknown> {\n type: string;\n recipient: NotificationRecipient;\n payload: TPayload;\n}\n\n/**\n * Maps one domain event type to agnostic content. Registered through the\n * server config (or `registerGenerator` for a late arrival); adding a\n * generator never touches existing generators, the router, or any transport\n * (open/closed).\n */\n/**\n * Who the content is being rendered FOR — the reader, at the moment the\n * generator is asked.\n *\n * A notification is stored as rendered TEXT: title and body are columns, so\n * the language is chosen once, when the row is written, and never again. That\n * makes this the only honest place to ask. A generator is registered at BOOT —\n * a host that resolved its words there would pin every future reader to\n * whichever language the process happened to start in, invisibly, because a\n * single-locale host cannot tell the difference.\n *\n * The tag is the RECIPIENT's, never the request's. The person who triggers a\n * notification is routinely not the person who reads it: an invite is sent\n * because an administrator acted and is read by the invitee. Reading\n * `Accept-Language` here would be a bug that only ever surfaces as somebody\n * being told things in a language they do not speak.\n *\n * Absent means \"nobody said\" — a host with one audience, or one that stores no\n * per-user language, populates nothing and every generator answers with its\n * own default exactly as it did before this existed.\n */\nexport interface NotificationGenerateContext {\n readonly locale?: string | null;\n}\n\nexport interface NotificationGenerator<TPayload = unknown> {\n /** The event key, dot-namespaced (\"order.paid\"). One generator per type. */\n type: string;\n /** The preference category the router gates this type's fan-out on. */\n category: NotificationCategory;\n /**\n * Render this event's content for ONE recipient.\n *\n * `context` is OPTIONAL, and that is what keeps every generator written\n * before it working: a one-parameter `generate` is assignable to this\n * signature unchanged. A host that passes nothing is stating a fact — it has\n * no language for this reader — rather than asserting a default.\n */\n generate: (payload: TPayload, context?: NotificationGenerateContext) => NotificationContent;\n}\n\n/**\n * The recipient as a transport sees them: resolved destinations only. Built by\n * the router from the host's contact directory + the push subscriptions this\n * package owns; transports use it to answer\n * {@link NotificationTransport.supports}.\n */\nexport interface TransportRecipient {\n userId: string;\n email: string | null;\n /**\n * The recipient's own language, when the host's contact directory states\n * one. Absent means \"nobody said\" — see {@link NotificationGenerateContext}.\n */\n locale?: string | null;\n /** Phone as the host stores it (transports normalize per provider rules). */\n phone: string | null;\n /** How many active browser push subscriptions the user holds. */\n pushSubscriptionCount: number;\n}\n\n/**\n * One pluggable channel adapter: a FORMATTER (agnostic content → channel\n * message) plus a SENDER. Adding a channel = registering one of these; the\n * router dispatches through the registry and needs no change.\n *\n * `send` resolves on success and THROWS on failure — the router records the\n * error on the delivery row and isolates it from other channels. Sends must be\n * retry-safe: the router may re-dispatch a QUEUED/FAILED delivery.\n */\nexport interface NotificationTransport<TMessage = unknown> {\n channel: NotificationChannel;\n /**\n * Whether this recipient is addressable on this channel right now — the\n * destination exists (e-mail / phone / push subscription) AND the provider\n * is configured. `false` simply skips the channel (no delivery row).\n */\n supports(recipient: TransportRecipient): boolean;\n /** Transform the agnostic content into this channel's message shape. */\n format(content: NotificationContent): TMessage;\n /** Deliver the formatted message to the recipient. Throws on failure. */\n send(message: TMessage, recipient: TransportRecipient): Promise<void>;\n}\n\n/**\n * The host's product vocabulary. Everything below the surface (routing,\n * delivery rows, retries, the wire) is identical for every host; WHICH\n * categories exist and how they are labelled is not.\n */\nexport interface NotificationTaxonomy {\n /** The preference categories, in the order the settings screen lists them. */\n categories: readonly NotificationCategory[];\n}\n\n/**\n * The taxonomy in force. `categories` is REQUIRED — see above.\n *\n * The empty check was already here and stays: an empty list and a missing one\n * are the same mistake, and both now fail at assembly rather than rendering an\n * empty settings screen or somebody else's four rows.\n */\nexport function taxonomyOf(config: {\n categories: readonly NotificationCategory[];\n}): NotificationTaxonomy {\n const categories = config.categories;\n if (!categories || categories.length === 0) {\n throw new Error(\n '@12-apps/notifications: `categories` is required and must not be empty — ' +\n 'the preference categories are the host\\'s product vocabulary.',\n );\n }\n return { categories: [...categories] };\n}\n\n/** The host's logger. Defaults to the console (the @12-apps/jobs precedent). */\nexport interface NotificationLogger {\n info(message: string, ...meta: unknown[]): void;\n error(message: string, ...meta: unknown[]): void;\n}\n"],"mappings":";;;;;AAgBO,IAAM,wBAAwB,CAAC,SAAS,OAAO,YAAY,UAAU;AA2LrE,SAAS,WAAW,QAEF;AACvB,QAAM,aAAa,OAAO;AAC1B,MAAI,CAAC,cAAc,WAAW,WAAW,GAAG;AAC1C,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO,EAAE,YAAY,CAAC,GAAG,UAAU,EAAE;AACvC;AAXgB;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/generators.ts","../src/preferences-core.ts","../src/phone.ts","../src/wire.ts"],"sourcesContent":["/** Thrown by `notify` when no generator is registered for the event type. */\nexport class UnknownNotificationTypeError extends Error {\n readonly type: string;\n constructor(type: string) {\n super(`No notification generator registered for type \"${type}\".`);\n this.name = 'UnknownNotificationTypeError';\n this.type = type;\n Object.setPrototypeOf(this, UnknownNotificationTypeError.prototype);\n }\n}\n\n/** Thrown by `notify` when the recipient has no contact record in the host. */\nexport class UnknownNotificationRecipientError extends Error {\n readonly userId: string;\n constructor(userId: string) {\n super(`notify(): unknown recipient user \"${userId}\".`);\n this.name = 'UnknownNotificationRecipientError';\n this.userId = userId;\n Object.setPrototypeOf(this, UnknownNotificationRecipientError.prototype);\n }\n}\n","import { UnknownNotificationTypeError } from './errors';\nimport type { NotificationGenerator } from './types';\n\n/**\n * Generator registry: one {@link NotificationGenerator} per event `type`.\n *\n * INSTANCE state, not a module-level Map. the origin's own was\n * process-wide, which is what a package of loose functions forces; a factory\n * config does not need it, and one registry per mount is what makes a test (or\n * a second mount) able to hold its own set without clearing anyone else's.\n * Domain modules still register from the OUTSIDE — that is the open/closed\n * seam the whole pipeline is built on — either through the server config's\n * `generators` array or through `registerGenerator` for a late arrival.\n */\nexport interface NotificationGeneratorRegistry {\n /** Register (last-wins, so a re-import is idempotent). */\n register<TPayload>(generator: NotificationGenerator<TPayload>): void;\n /** Resolve for `type`, throwing {@link UnknownNotificationTypeError}. */\n resolve(type: string): NotificationGenerator<never>;\n /** Whether a generator is registered for `type` (emit-site guard). */\n has(type: string): boolean;\n /** Every registered type, for diagnostics. */\n types(): string[];\n}\n\nexport function createGeneratorRegistry(\n initial: readonly NotificationGenerator<never>[] = [],\n): NotificationGeneratorRegistry {\n const generators = new Map<string, NotificationGenerator<never>>();\n const registry: NotificationGeneratorRegistry = {\n register(generator) {\n generators.set(generator.type, generator as NotificationGenerator<never>);\n },\n resolve(type) {\n const generator = generators.get(type);\n if (!generator) throw new UnknownNotificationTypeError(type);\n return generator;\n },\n has: (type) => generators.has(type),\n types: () => [...generators.keys()],\n };\n for (const generator of initial) registry.register(generator);\n return registry;\n}\n","import {\n NOTIFICATION_CHANNELS,\n type NotificationCategory,\n type NotificationChannel,\n} from './types';\n\n/**\n * The preference POLICY, with no storage in it (12-15): which channels a\n * category defaults to, how a stored JSON row is coerced onto the closed\n * channel set, and how a partial save merges. `./server`'s store is the only\n * thing that touches a database, so every rule here is unit-testable without\n * one — and the react half can render the same defaults before the first read\n * lands.\n *\n * Storage stores only EXPLICIT choices (one row per (user, category)); a\n * missing row — or a missing channel key inside a row — falls back to\n * {@link DEFAULT_CHANNEL_ROW}. Defaults: the free, low-friction channels\n * (e-mail + web push) on; the paid per-message channels (SMS + WhatsApp) off\n * until the user opts in. A host that disagrees passes `channelDefaults`.\n */\n\n/** One category's channel toggles. */\nexport type ChannelRow = Record<NotificationChannel, boolean>;\n\n/** A user's full category × channel matrix. */\nexport type ChannelMatrix = Record<NotificationCategory, ChannelRow>;\n\n/** The policy applied when a user never touched a category's toggles. */\nexport const DEFAULT_CHANNEL_ROW: ChannelRow = {\n EMAIL: true,\n SMS: false,\n WHATSAPP: false,\n WEB_PUSH: true,\n};\n\n/** The default matrix for one taxonomy (what the settings UI starts from). */\nexport function defaultChannelMatrix(\n categories: readonly NotificationCategory[],\n channelDefaults: Partial<ChannelRow> = {},\n): ChannelMatrix {\n const row = { ...DEFAULT_CHANNEL_ROW, ...channelDefaults };\n return Object.fromEntries(\n categories.map((category) => [category, { ...row }]),\n ) as ChannelMatrix;\n}\n\n/**\n * Coerce a stored JSON `channels` map onto the closed channel set, filling the\n * gaps from `base`. A stored row that predates a channel keeps that channel's\n * default rather than reading as \"off\", which is what lets a new transport ship\n * without a data migration.\n *\n * The consequence, and the rule it implies: a channel ADDED later turns itself\n * ON for a user who had explicitly switched every channel in that category off,\n * because their stored row has no key for it. That is harmless for the four\n * shipped channels — the two that cost money default off — so **a new channel\n * must be added with a `false` default** unless the user's existing consent\n * already covers it. The alternative (reading a missing key as \"off\") would need\n * a data migration for every existing row on every channel that ever ships.\n */\nexport function mergeStoredRow(stored: unknown, base: ChannelRow): ChannelRow {\n const row = { ...base };\n if (stored && typeof stored === 'object') {\n const record = stored as Record<string, unknown>;\n for (const channel of NOTIFICATION_CHANNELS) {\n const value = record[channel];\n if (typeof value === 'boolean') row[channel] = value;\n }\n }\n return row;\n}\n\n/** The channels enabled by one effective row — the router's gate. */\nexport function enabledChannelsOf(row: ChannelRow): NotificationChannel[] {\n return NOTIFICATION_CHANNELS.filter((channel) => row[channel]);\n}\n\n/**\n * What a PUT writes for one category: the caller's toggles merged over the\n * user's CURRENT effective row. A single-toggle save (how the settings UI\n * writes) must never reset the category's other channels back to their\n * defaults, which is exactly what a whole-row write would do.\n */\nexport function mergeChoices(\n current: ChannelRow,\n choices: Partial<ChannelRow>,\n): ChannelRow {\n return { ...current, ...choices };\n}\n","/**\n * Shared phone-destination rules for the SMS + WhatsApp transports.\n *\n * Providers need E.164 (`+5531999998888`); a host stores the phone as the user\n * entered it. Best-effort normalization: an explicit `+` prefix is trusted; a\n * bare 10/11-digit number is assumed to belong to `defaultCountryCode` and\n * prefixed; anything else is unusable and makes the channel unavailable for\n * that recipient.\n *\n * `defaultCountryCode` is REQUIRED, and that is the whole point of it being a\n * parameter. It used to default to `55` (Brazil, the first host's market),\n * which a published package must not do: a US adopter that never set it turned\n * `4155552671` into `+554155552671` — a plausible Brazilian mobile — and sent a\n * stranger the customer's order details. There is no country this package could\n * assume that is not wrong for every other adopter, so it assumes none and the\n * omission is a compile error rather than a wrong number. the origin passes\n * `'55'` explicitly.\n *\n * NOTE: \"verified phone\" is approximated by \"has a normalizable phone on\n * file\" — a host with a real verification flow should tighten its contact\n * directory to only return verified numbers, which is the single seam both\n * transports funnel through.\n */\n\n/** Options for {@link normalizePhoneE164}. */\nexport interface PhoneNormalizeOptions {\n /**\n * Country calling code for a bare local number, digits only (`'55'`, `'1'`).\n * Required: see the module docstring for why there is no default.\n */\n defaultCountryCode: string;\n}\n\n/** A local subscriber number: area code (2) + 8 or 9 digits. */\nconst isLocal = (digits: string): boolean => digits.length === 10 || digits.length === 11;\n\n/** An already-international number. E.164 allows 8..15 digits. */\nconst international = (digits: string): string | null =>\n digits.length >= 8 && digits.length <= 15 ? `+${digits}` : null;\n\n/** Normalize a stored phone to E.164, or null when it can't be inferred. */\nexport function normalizePhoneE164(\n raw: string | null | undefined,\n options: PhoneNormalizeOptions,\n): string | null {\n if (!raw) return null;\n const country = options.defaultCountryCode;\n const trimmed = raw.trim();\n const digits = trimmed.replace(/\\D/g, '');\n if (trimmed.startsWith('+')) return international(digits);\n if (isLocal(digits)) return `+${country}${digits}`;\n // A bare number that already carries the country code.\n if (digits.startsWith(country) && isLocal(digits.slice(country.length))) {\n return `+${digits}`;\n }\n return null;\n}\n","/**\n * The inbox WIRE shape — the one contract the two halves share.\n *\n * It lives in the root entry rather than in `./server` or `./react` because\n * both halves need it and neither owns it: the api serializes to it, the panel\n * deserializes from it, and a change here is a change to both at once. That is\n * the same reason the response envelope and the route paths are the package's\n * and not the host's.\n */\n\n/** One inbox entry as the notification centre renders it. */\nexport interface InboxNotification {\n id: string;\n type: string;\n category: string;\n title: string;\n body: string;\n link: string | null;\n data: Record<string, unknown>;\n /** ISO-8601, or null while unread. */\n readAt: string | null;\n /** ISO-8601. */\n createdAt: string;\n}\n\n/** One page of the owner's inbox. */\nexport interface ListNotificationsResult {\n items: InboxNotification[];\n /** Cursor for the next page, or null when this page is the last. */\n nextCursor: string | null;\n}\n\n/** A stored notification row, as the db seam hands it back. */\nexport interface NotificationRow {\n id: string;\n userId: string;\n clientId: string | null;\n type: string;\n category: string;\n title: string;\n body: string;\n link: string | null;\n data: unknown;\n readAt: Date | null;\n deletedAt: Date | null;\n createdAt: Date;\n}\n\n/** Row → wire. Dates become ISO strings; a null `data` becomes `{}`. */\nexport function inboxWire(row: NotificationRow): InboxNotification {\n return {\n id: row.id,\n type: row.type,\n category: row.category,\n title: row.title,\n body: row.body,\n link: row.link,\n data: (row.data ?? {}) as Record<string, unknown>,\n readAt: row.readAt ? row.readAt.toISOString() : null,\n createdAt: row.createdAt.toISOString(),\n };\n}\n"],"mappings":"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","names":[]}