@12-apps/notifications 4.12.0 → 4.14.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 (55) hide show
  1. package/dist/{chunk-WZBX7YCE.js → chunk-53SH5ABN.js} +82 -34
  2. package/dist/chunk-53SH5ABN.js.map +1 -0
  3. package/dist/{chunk-PNY6S6WH.js → chunk-643HNPQJ.js} +4 -4
  4. package/dist/chunk-643HNPQJ.js.map +1 -0
  5. package/dist/{chunk-2IAHFIXS.js → chunk-AN6IX4AE.js} +4 -3
  6. package/dist/chunk-AN6IX4AE.js.map +1 -0
  7. package/dist/{chunk-CPQKKLPS.js → chunk-CSMFJJXY.js} +1 -1
  8. package/dist/{chunk-CPQKKLPS.js.map → chunk-CSMFJJXY.js.map} +1 -1
  9. package/dist/{chunk-FBBPS2LT.js → chunk-QPO6NSRR.js} +2 -2
  10. package/dist/{chunk-GK6GSC2J.js → chunk-XFODKRRB.js} +2 -2
  11. package/dist/{create-api-notifications-BTudlaSC.d.ts → create-api-notifications-cIEbFqoZ.d.ts} +121 -30
  12. package/dist/{create-web-notifications-2xxbKnrW.d.ts → create-web-notifications-32Myo-ww.d.ts} +31 -9
  13. package/dist/{generators-CQYdJfB5.d.ts → generators-GUF-Kml-.d.ts} +1 -1
  14. package/dist/hono/index.d.ts +5 -5
  15. package/dist/hono/index.js +3 -3
  16. package/dist/index.d.ts +3 -3
  17. package/dist/index.js +2 -2
  18. package/dist/{jobs-CaovU4GM.d.ts → jobs-wKoOz91Q.d.ts} +1 -1
  19. package/dist/manifest/server.d.ts +6 -6
  20. package/dist/manifest/server.js +4 -4
  21. package/dist/manifest/web.d.ts +3 -3
  22. package/dist/manifest/web.js +2 -2
  23. package/dist/{panel-MKI4PTNZ.js → panel-JL5RDEHY.js} +2 -2
  24. package/dist/{preferences-screen-S3ZHX5LB.js → preferences-screen-YRPNUZS5.js} +2 -2
  25. package/dist/react/index.d.ts +4 -4
  26. package/dist/react/index.js +2 -2
  27. package/dist/server/index.d.ts +7 -7
  28. package/dist/server/index.js +4 -4
  29. package/dist/{types-H_aFzLA0.d.ts → types-DPiePHJD.d.ts} +19 -1
  30. package/dist/web-push/index.d.ts +2 -2
  31. package/dist/{web-push-C6U-5JCV.d.ts → web-push-DUn_d_gj.d.ts} +12 -2
  32. package/dist/{wire-Bn6aA2nL.d.ts → wire-CJka1AvM.d.ts} +1 -1
  33. package/package.json +2 -2
  34. package/prisma/migrations/20260914210000_scope_push_subscriptions_per_store/migration.sql +31 -0
  35. package/prisma/notifications.prisma +12 -0
  36. package/src/react/create-web-notifications.tsx +12 -2
  37. package/src/react/hooks.ts +24 -4
  38. package/src/react/index.ts +1 -0
  39. package/src/server/context.ts +15 -0
  40. package/src/server/db.ts +50 -8
  41. package/src/server/dispatch.ts +9 -2
  42. package/src/server/inbox.ts +87 -18
  43. package/src/server/index.ts +2 -0
  44. package/src/server/push-subscriptions.ts +61 -7
  45. package/src/server/router.ts +5 -1
  46. package/src/server/routes.ts +14 -5
  47. package/src/server/transports/web-push.ts +15 -2
  48. package/src/types.ts +19 -1
  49. package/dist/chunk-2IAHFIXS.js.map +0 -1
  50. package/dist/chunk-PNY6S6WH.js.map +0 -1
  51. package/dist/chunk-WZBX7YCE.js.map +0 -1
  52. /package/dist/{chunk-FBBPS2LT.js.map → chunk-QPO6NSRR.js.map} +0 -0
  53. /package/dist/{chunk-GK6GSC2J.js.map → chunk-XFODKRRB.js.map} +0 -0
  54. /package/dist/{panel-MKI4PTNZ.js.map → panel-JL5RDEHY.js.map} +0 -0
  55. /package/dist/{preferences-screen-S3ZHX5LB.js.map → preferences-screen-YRPNUZS5.js.map} +0 -0
@@ -9,7 +9,7 @@ import {
9
9
  mergeStoredRow,
10
10
  normalizePhoneE164,
11
11
  resolveTypeChannels
12
- } from "./chunk-GK6GSC2J.js";
12
+ } from "./chunk-XFODKRRB.js";
13
13
  import {
14
14
  messagesOf
15
15
  } from "./chunk-M2TVBVH2.js";
@@ -19,7 +19,7 @@ import {
19
19
  import {
20
20
  NOTIFICATION_CHANNELS,
21
21
  taxonomyOf
22
- } from "./chunk-CPQKKLPS.js";
22
+ } from "./chunk-CSMFJJXY.js";
23
23
  import {
24
24
  renderEmail
25
25
  } from "./chunk-EKUSNUBT.js";
@@ -439,7 +439,7 @@ function webPushTransport(declaration, subscriptions, extraDrivers = {}) {
439
439
  supports: /* @__PURE__ */ __name((recipient) => recipient.pushSubscriptionCount > 0, "supports"),
440
440
  format: formatWebPush,
441
441
  async send(message, recipient) {
442
- const rows = await subscriptions.list(recipient.userId);
442
+ const rows = await subscriptions.list(recipient.userId, recipient.clientId);
443
443
  if (rows.length === 0) throw new Error("Recipient no longer has push subscriptions.");
444
444
  const payload = JSON.stringify(message);
445
445
  let delivered = 0;
@@ -682,7 +682,7 @@ var DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
682
682
  var DEFAULT_SWEEP_TAKE = 200;
683
683
  var DEFAULT_SWEEP_CUTOFF_MS = 5 * 6e4;
684
684
  var RETRYABLE = ["FAILED", "QUEUED", "SENDING"];
685
- async function loadRecipient(deps, userId) {
685
+ async function loadRecipient(deps, userId, clientId) {
686
686
  const contact = await deps.contacts.getContact(userId);
687
687
  if (!contact) return null;
688
688
  return {
@@ -693,7 +693,11 @@ async function loadRecipient(deps, userId) {
693
693
  // absent case has to stay distinguishable from a stated language, because
694
694
  // that is what lets a generator apply its own default in one place.
695
695
  ...contact.locale === void 0 ? {} : { locale: contact.locale },
696
- pushSubscriptionCount: await deps.pushSubscriptions.count(userId)
696
+ clientId,
697
+ // SCOPED, and this is what keeps `supports()` honest: it gates on this
698
+ // number, so an unscoped count would enqueue a WEB_PUSH delivery for a
699
+ // notification no reachable subscription exists for.
700
+ pushSubscriptionCount: await deps.pushSubscriptions.count(userId, clientId)
697
701
  };
698
702
  }
699
703
  __name(loadRecipient, "loadRecipient");
@@ -759,7 +763,7 @@ async function dispatchOne(deps, notificationId) {
759
763
  where: { notificationId, status: "QUEUED" }
760
764
  });
761
765
  if (queued.length === 0) return 0;
762
- const recipient = await loadRecipient(deps, notification.userId);
766
+ const recipient = await loadRecipient(deps, notification.userId, notification.clientId);
763
767
  if (!recipient) {
764
768
  await abandonUnreachable(deps, client, queued, notification.userId);
765
769
  return 0;
@@ -805,6 +809,10 @@ async function drainPending(deps, olderThanMs, take) {
805
809
  __name(drainPending, "drainPending");
806
810
 
807
811
  // src/server/inbox.ts
812
+ function scopeBranch(scope) {
813
+ return scope === void 0 ? [] : [{ OR: [{ clientId: scope }, { clientId: null }] }];
814
+ }
815
+ __name(scopeBranch, "scopeBranch");
808
816
  var DEFAULT_PAGE = 20;
809
817
  var MAX_PAGE = 100;
810
818
  async function resolveAnchor(notifications, userId, cursor) {
@@ -813,31 +821,47 @@ async function resolveAnchor(notifications, userId, cursor) {
813
821
  return { createdAt: anchor.createdAt, id: anchor.id };
814
822
  }
815
823
  __name(resolveAnchor, "resolveAnchor");
816
- function pageWhere(userId, filter, anchor) {
824
+ function pageWhere(userId, filter, anchor, scope) {
825
+ const clauses = [
826
+ // `(createdAt, id) < (anchor.createdAt, anchor.id)`, as a portable `where`.
827
+ ...anchor ? [
828
+ {
829
+ OR: [
830
+ { createdAt: { lt: anchor.createdAt } },
831
+ { createdAt: anchor.createdAt, id: { lt: anchor.id } }
832
+ ]
833
+ }
834
+ ] : [],
835
+ ...scopeBranch(scope)
836
+ ];
817
837
  return {
818
838
  userId,
819
839
  deletedAt: null,
820
840
  ...filter === "unread" ? { readAt: null } : {},
821
- // `(createdAt, id) < (anchor.createdAt, anchor.id)`, as a portable `where`.
822
- ...anchor ? {
823
- OR: [
824
- { createdAt: { lt: anchor.createdAt } },
825
- { createdAt: anchor.createdAt, id: { lt: anchor.id } }
826
- ]
827
- } : {}
841
+ ...clauses.length > 0 ? { AND: clauses } : {}
828
842
  };
829
843
  }
830
844
  __name(pageWhere, "pageWhere");
845
+ function unreadWhere(userId, scope) {
846
+ const scoped = scopeBranch(scope);
847
+ return {
848
+ userId,
849
+ deletedAt: null,
850
+ readAt: null,
851
+ ...scoped.length > 0 ? { AND: scoped } : {}
852
+ };
853
+ }
854
+ __name(unreadWhere, "unreadWhere");
831
855
  function createInboxStore(db) {
832
856
  return {
833
857
  /** The owner's inbox, newest first, keyset-paginated, deleted excluded. */
834
- async list(userId, input = {}) {
858
+ async list(userId, input = {}, scope) {
835
859
  const client = await db();
836
860
  const limit = Math.min(Math.max(input.limit ?? DEFAULT_PAGE, 1), MAX_PAGE);
837
861
  const anchor = input.cursor ? await resolveAnchor(client.notification, userId, input.cursor) : void 0;
838
862
  if (input.cursor && !anchor) return { items: [], nextCursor: null };
839
863
  const rows = await client.notification.findMany({
840
- where: pageWhere(userId, input.filter, anchor),
864
+ where: pageWhere(userId, input.filter, anchor, scope),
841
865
  // `id` tie-breaks equal timestamps so pages never skip/repeat.
842
866
  orderBy: [{ createdAt: "desc" }, { id: "desc" }],
843
867
  take: limit + 1
@@ -849,9 +873,9 @@ function createInboxStore(db) {
849
873
  };
850
874
  },
851
875
  /** Unread badge count (non-deleted, unread). */
852
- async unreadCount(userId) {
876
+ async unreadCount(userId, scope) {
853
877
  const client = await db();
854
- return client.notification.count({ where: { userId, deletedAt: null, readAt: null } });
878
+ return client.notification.count({ where: unreadWhere(userId, scope) });
855
879
  },
856
880
  /**
857
881
  * Mark specific notifications read. Only the owner's own, still-unread,
@@ -868,10 +892,10 @@ function createInboxStore(db) {
868
892
  return result.count;
869
893
  },
870
894
  /** Mark every unread notification of the owner read ("mark all"). */
871
- async markAllRead(userId) {
895
+ async markAllRead(userId, scope) {
872
896
  const client = await db();
873
897
  const result = await client.notification.updateMany({
874
- where: { userId, deletedAt: null, readAt: null },
898
+ where: unreadWhere(userId, scope),
875
899
  data: { readAt: /* @__PURE__ */ new Date() }
876
900
  });
877
901
  return result.count;
@@ -962,6 +986,11 @@ function createPreferenceStore(db, taxonomy, channelDefaults = {}) {
962
986
  __name(createPreferenceStore, "createPreferenceStore");
963
987
 
964
988
  // src/server/push-subscriptions.ts
989
+ function reachableBy(userId, notificationClientId) {
990
+ if (notificationClientId === void 0 || notificationClientId === null) return { userId };
991
+ return { userId, OR: [{ clientId: null }, { clientId: notificationClientId }] };
992
+ }
993
+ __name(reachableBy, "reachableBy");
965
994
  function createPushSubscriptionStore(db, logger) {
966
995
  return {
967
996
  async save(userId, input) {
@@ -981,12 +1010,18 @@ function createPushSubscriptionStore(db, logger) {
981
1010
  endpoint: input.endpoint,
982
1011
  p256dh: input.keys.p256dh,
983
1012
  auth: input.keys.auth,
1013
+ clientId: input.clientId ?? null,
984
1014
  userAgent: input.userAgent ?? null
985
1015
  },
1016
+ // Re-stamped on every save, so the same browser moving between a store's
1017
+ // app and the platform corrects its own scope rather than keeping the
1018
+ // first origin it ever subscribed from. A scope change on the SAME user
1019
+ // is not a re-own and must not log one.
986
1020
  update: {
987
1021
  userId,
988
1022
  p256dh: input.keys.p256dh,
989
1023
  auth: input.keys.auth,
1024
+ clientId: input.clientId ?? null,
990
1025
  userAgent: input.userAgent ?? null
991
1026
  }
992
1027
  });
@@ -995,18 +1030,20 @@ function createPushSubscriptionStore(db, logger) {
995
1030
  const client = await db();
996
1031
  await client.pushSubscription.deleteMany({ where: { userId, endpoint } });
997
1032
  },
998
- async count(userId) {
1033
+ async count(userId, notificationClientId) {
999
1034
  const client = await db();
1000
- return client.pushSubscription.count({ where: { userId } });
1035
+ return client.pushSubscription.count({ where: reachableBy(userId, notificationClientId) });
1001
1036
  },
1002
1037
  async isRegisteredTo(userId, endpoint) {
1003
1038
  const client = await db();
1004
1039
  const row = await client.pushSubscription.findUnique({ where: { endpoint } });
1005
1040
  return row?.userId === userId;
1006
1041
  },
1007
- async list(userId) {
1042
+ async list(userId, notificationClientId) {
1008
1043
  const client = await db();
1009
- const rows = await client.pushSubscription.findMany({ where: { userId } });
1044
+ const rows = await client.pushSubscription.findMany({
1045
+ where: reachableBy(userId, notificationClientId)
1046
+ });
1010
1047
  return rows.map((row) => ({
1011
1048
  id: row.id,
1012
1049
  endpoint: row.endpoint,
@@ -1023,12 +1060,13 @@ function createPushSubscriptionStore(db, logger) {
1023
1060
  __name(createPushSubscriptionStore, "createPushSubscriptionStore");
1024
1061
 
1025
1062
  // src/server/routes.ts
1026
- async function channelAvailability(deps, userId) {
1063
+ async function channelAvailability(deps, userId, scopeClientId) {
1027
1064
  const contact = await deps.contacts.getContact(userId);
1028
1065
  const recipient = {
1029
1066
  userId,
1030
1067
  email: contact?.email ?? null,
1031
1068
  phone: contact?.phone ?? null,
1069
+ clientId: scopeClientId,
1032
1070
  pushSubscriptionCount: 1
1033
1071
  };
1034
1072
  return Object.fromEntries(
@@ -1039,10 +1077,10 @@ async function channelAvailability(deps, userId) {
1039
1077
  );
1040
1078
  }
1041
1079
  __name(channelAvailability, "channelAvailability");
1042
- async function preferencesPayload(deps, userId) {
1080
+ async function preferencesPayload(deps, userId, scopeClientId) {
1043
1081
  const [preferences, availability] = await Promise.all([
1044
1082
  deps.preferences.get(userId),
1045
- channelAvailability(deps, userId)
1083
+ channelAvailability(deps, userId, scopeClientId)
1046
1084
  ]);
1047
1085
  return { preferences, availability, categories: [...deps.categories] };
1048
1086
  }
@@ -1056,7 +1094,8 @@ function inboxRoutes(deps) {
1056
1094
  async ({ actor, query, locale }) => ok(
1057
1095
  await deps.inbox.list(
1058
1096
  actor.userId,
1059
- parseListQuery(query, messagesOf(deps, locale))
1097
+ parseListQuery(query, messagesOf(deps, locale)),
1098
+ actor.scopeClientId
1060
1099
  )
1061
1100
  )
1062
1101
  )
@@ -1067,7 +1106,7 @@ function inboxRoutes(deps) {
1067
1106
  handle: guarded(
1068
1107
  async ({ actor }) => (
1069
1108
  // Polled by the SPAs, so it stays a single indexed COUNT.
1070
- ok({ count: await deps.inbox.unreadCount(actor.userId) })
1109
+ ok({ count: await deps.inbox.unreadCount(actor.userId, actor.scopeClientId) })
1071
1110
  )
1072
1111
  )
1073
1112
  },
@@ -1076,7 +1115,7 @@ function inboxRoutes(deps) {
1076
1115
  path: "/notifications/mark-read",
1077
1116
  handle: guarded(async ({ actor, body, locale }) => {
1078
1117
  const target = parseMarkReadBody(body, messagesOf(deps, locale));
1079
- const updated = "all" in target ? await deps.inbox.markAllRead(actor.userId) : await deps.inbox.markRead(actor.userId, target.ids);
1118
+ const updated = "all" in target ? await deps.inbox.markAllRead(actor.userId, actor.scopeClientId) : await deps.inbox.markRead(actor.userId, target.ids);
1080
1119
  if (updated > 0) deps.onInboxChanged?.(actor.userId);
1081
1120
  return ok({ updated });
1082
1121
  })
@@ -1102,7 +1141,9 @@ function preferenceRoutes(deps) {
1102
1141
  {
1103
1142
  method: "GET",
1104
1143
  path: "/notification-preferences",
1105
- handle: guarded(async ({ actor }) => ok(await preferencesPayload(deps, actor.userId)))
1144
+ handle: guarded(
1145
+ async ({ actor }) => ok(await preferencesPayload(deps, actor.userId, actor.scopeClientId ?? null))
1146
+ )
1106
1147
  },
1107
1148
  {
1108
1149
  method: "PUT",
@@ -1112,7 +1153,7 @@ function preferenceRoutes(deps) {
1112
1153
  actor.userId,
1113
1154
  parsePreferencesBody(body, messagesOf(deps, locale))
1114
1155
  );
1115
- return ok(await preferencesPayload(deps, actor.userId));
1156
+ return ok(await preferencesPayload(deps, actor.userId, actor.scopeClientId ?? null));
1116
1157
  })
1117
1158
  }
1118
1159
  ];
@@ -1143,6 +1184,9 @@ function pushRoutes(deps) {
1143
1184
  const input = parsePushSubscriptionBody(body, messagesOf(deps, locale));
1144
1185
  const userAgent = headers?.["user-agent"];
1145
1186
  await deps.pushSubscriptions.save(actor.userId, {
1187
+ // The HOST's resolved origin, never the body's — a caller cannot
1188
+ // choose which store's app their browser counts as.
1189
+ clientId: actor.scopeClientId ?? null,
1146
1190
  ...input,
1147
1191
  ...userAgent ? { userAgent } : {}
1148
1192
  });
@@ -1249,7 +1293,11 @@ function createNotificationRouter(deps) {
1249
1293
  drainPending: /* @__PURE__ */ __name((olderThanMs = DEFAULT_SWEEP_CUTOFF_MS, take = DEFAULT_SWEEP_TAKE) => drainPending(deps, olderThanMs, take), "drainPending"),
1250
1294
  async notify(event, options = {}) {
1251
1295
  const generator = deps.generators.resolve(event.type);
1252
- const recipient = await loadRecipient(deps, event.recipient.userId);
1296
+ const recipient = await loadRecipient(
1297
+ deps,
1298
+ event.recipient.userId,
1299
+ event.recipient.clientId ?? null
1300
+ );
1253
1301
  if (!recipient) throw new UnknownNotificationRecipientError(event.recipient.userId);
1254
1302
  const content = generator.generate(event.payload, { locale: recipient.locale });
1255
1303
  const channels = await resolveChannels(deps, event, generator, recipient);
@@ -1370,4 +1418,4 @@ export {
1370
1418
  createTransportRegistry,
1371
1419
  createApiNotifications
1372
1420
  };
1373
- //# sourceMappingURL=chunk-WZBX7YCE.js.map
1421
+ //# sourceMappingURL=chunk-53SH5ABN.js.map