@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.
- package/dist/{chunk-WZBX7YCE.js → chunk-53SH5ABN.js} +82 -34
- package/dist/chunk-53SH5ABN.js.map +1 -0
- package/dist/{chunk-PNY6S6WH.js → chunk-643HNPQJ.js} +4 -4
- package/dist/chunk-643HNPQJ.js.map +1 -0
- package/dist/{chunk-2IAHFIXS.js → chunk-AN6IX4AE.js} +4 -3
- package/dist/chunk-AN6IX4AE.js.map +1 -0
- package/dist/{chunk-CPQKKLPS.js → chunk-CSMFJJXY.js} +1 -1
- package/dist/{chunk-CPQKKLPS.js.map → chunk-CSMFJJXY.js.map} +1 -1
- package/dist/{chunk-FBBPS2LT.js → chunk-QPO6NSRR.js} +2 -2
- package/dist/{chunk-GK6GSC2J.js → chunk-XFODKRRB.js} +2 -2
- package/dist/{create-api-notifications-BTudlaSC.d.ts → create-api-notifications-cIEbFqoZ.d.ts} +121 -30
- package/dist/{create-web-notifications-2xxbKnrW.d.ts → create-web-notifications-32Myo-ww.d.ts} +31 -9
- package/dist/{generators-CQYdJfB5.d.ts → generators-GUF-Kml-.d.ts} +1 -1
- package/dist/hono/index.d.ts +5 -5
- package/dist/hono/index.js +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/{jobs-CaovU4GM.d.ts → jobs-wKoOz91Q.d.ts} +1 -1
- package/dist/manifest/server.d.ts +6 -6
- package/dist/manifest/server.js +4 -4
- package/dist/manifest/web.d.ts +3 -3
- package/dist/manifest/web.js +2 -2
- package/dist/{panel-MKI4PTNZ.js → panel-JL5RDEHY.js} +2 -2
- package/dist/{preferences-screen-S3ZHX5LB.js → preferences-screen-YRPNUZS5.js} +2 -2
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +2 -2
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +4 -4
- package/dist/{types-H_aFzLA0.d.ts → types-DPiePHJD.d.ts} +19 -1
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-C6U-5JCV.d.ts → web-push-DUn_d_gj.d.ts} +12 -2
- package/dist/{wire-Bn6aA2nL.d.ts → wire-CJka1AvM.d.ts} +1 -1
- package/package.json +2 -2
- package/prisma/migrations/20260914210000_scope_push_subscriptions_per_store/migration.sql +31 -0
- package/prisma/notifications.prisma +12 -0
- package/src/react/create-web-notifications.tsx +12 -2
- package/src/react/hooks.ts +24 -4
- package/src/react/index.ts +1 -0
- package/src/server/context.ts +15 -0
- package/src/server/db.ts +50 -8
- package/src/server/dispatch.ts +9 -2
- package/src/server/inbox.ts +87 -18
- package/src/server/index.ts +2 -0
- package/src/server/push-subscriptions.ts +61 -7
- package/src/server/router.ts +5 -1
- package/src/server/routes.ts +14 -5
- package/src/server/transports/web-push.ts +15 -2
- package/src/types.ts +19 -1
- package/dist/chunk-2IAHFIXS.js.map +0 -1
- package/dist/chunk-PNY6S6WH.js.map +0 -1
- package/dist/chunk-WZBX7YCE.js.map +0 -1
- /package/dist/{chunk-FBBPS2LT.js.map → chunk-QPO6NSRR.js.map} +0 -0
- /package/dist/{chunk-GK6GSC2J.js.map → chunk-XFODKRRB.js.map} +0 -0
- /package/dist/{panel-MKI4PTNZ.js.map → panel-JL5RDEHY.js.map} +0 -0
- /package/dist/{preferences-screen-S3ZHX5LB.js.map → preferences-screen-YRPNUZS5.js.map} +0 -0
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "./chunk-CQZMTFPY.js";
|
|
5
5
|
import {
|
|
6
6
|
NOTIFICATION_CHANNELS
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-CSMFJJXY.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-
|
|
294
|
+
//# sourceMappingURL=preferences-screen-YRPNUZS5.js.map
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { L as LiveActivitiesConfig, N as NotificationsApiClient } from '../create-web-notifications-
|
|
2
|
-
export { B as BADGE_POLL_MS, a as BADGE_RECONCILE_MS, b as
|
|
1
|
+
import { L as LiveActivitiesConfig, N as NotificationsApiClient } from '../create-web-notifications-32Myo-ww.js';
|
|
2
|
+
export { B as BADGE_POLL_MS, a as BADGE_RECONCILE_MS, b as BadgeHookOptions, d as BadgeSyncOptions, e as BellBadge, f as BellButtonProps, I as InboxListStatus, g as InboxState, h as InboxStore, i as LiveActivitiesHook, j as LiveActivityMessages, k as NotificationsHttpError, l as NotificationsPanelProps, m as NotificationsResult, n as NotificationsSignalHook, o as NotificationsSubscribe, p as NotificationsTransport, q as NotificationsWebConfig, P as PAGE_SIZE, r as PreferencesPayload, s as PreferencesScreenProps, t as PushRegistrationPayload, W as WebNotifications, u as WebPushPlatformHint, v as WebPushSetupConfig, w as createInboxStore, x as createNotificationsApiClient, c as createWebNotifications, y as httpNotificationsTransport, z as useInboxList, A as useInboxState, C as useUnreadCount } from '../create-web-notifications-32Myo-ww.js';
|
|
3
3
|
import { JSX, ReactNode } from 'react';
|
|
4
4
|
import { b as LiveActivity } from '../live-DYxEFO49.js';
|
|
5
5
|
export { c as LiveActivityLane, d as LiveActivityStep, l as liveActivityLane } from '../live-DYxEFO49.js';
|
|
6
|
-
import { N as NotificationMessages } from '../wire-
|
|
7
|
-
import '../types-
|
|
6
|
+
import { N as NotificationMessages } from '../wire-CJka1AvM.js';
|
|
7
|
+
import '../types-DPiePHJD.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* What the reader has already been shown, so the bell can say NEW rather than
|
package/dist/react/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
createNotificationsApiClient,
|
|
4
4
|
createWebNotifications,
|
|
5
5
|
httpNotificationsTransport
|
|
6
|
-
} from "../chunk-
|
|
6
|
+
} from "../chunk-643HNPQJ.js";
|
|
7
7
|
import {
|
|
8
8
|
LiveSection,
|
|
9
9
|
relativeTime
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
useInboxList,
|
|
18
18
|
useInboxState,
|
|
19
19
|
useUnreadCount
|
|
20
|
-
} from "../chunk-
|
|
20
|
+
} from "../chunk-AN6IX4AE.js";
|
|
21
21
|
import {
|
|
22
22
|
disableWebPush,
|
|
23
23
|
enableWebPush,
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { A as ApiNotifications } from '../create-api-notifications-
|
|
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,
|
|
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-
|
|
1
|
+
import { A as ApiNotifications } from '../create-api-notifications-cIEbFqoZ.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, x as NotificationWhereBranch, a as NotificationsActor, y as NotificationsApiError, z as NotificationsDb, B as NotificationsDbClient, D as NotificationsDbProvider, F as NotificationsRequest, G as NotificationsResponse, b as NotificationsRoute, N as NotificationsServerConfig, H as NotifyByPermission, I as NotifyOptions, J as NotifyResult, P as PermissionNotificationResult, K as PermissionNotificationSkip, M as PushSubscriptionDelegate, O as PushSubscriptionInput, Q as PushSubscriptionRow, R as PushSubscriptionStore, S as PushSubscriptionWhere, T as SMS_DRIVERS, U as SmsDriver, V as SmsDriverDeclaration, W as SmsMessage, X as TransportDeclaration, Y as TransportRegistry, Z as WHATSAPP_DRIVERS, _ as WhatsAppDriver, $ as WhatsAppDriverDeclaration, a0 as WhatsAppMessage, a1 as createApiNotifications, a2 as createTransportRegistry, a3 as emailTransport, a4 as foldApiError, a5 as formatEmail, a6 as formatSms, a7 as formatWhatsApp, a8 as ok, a9 as smsTransport, aa as whatsAppTransport } from '../create-api-notifications-cIEbFqoZ.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-DUn_d_gj.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-
|
|
6
|
-
import '../generators-
|
|
7
|
-
import '../types-
|
|
8
|
-
import '../wire-
|
|
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-wKoOz91Q.js';
|
|
6
|
+
import '../generators-GUF-Kml-.js';
|
|
7
|
+
import '../types-DPiePHJD.js';
|
|
8
|
+
import '../wire-CJka1AvM.js';
|
|
9
9
|
import '../template-Dlt5S54z.js';
|
|
10
10
|
import '@12-apps/wiring';
|
|
11
11
|
|
package/dist/server/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
NOTIFICATIONS_JOBS,
|
|
5
5
|
NOTIFICATIONS_SWEEP_QUEUE,
|
|
6
6
|
wireNotifyPort
|
|
7
|
-
} from "../chunk-
|
|
7
|
+
} from "../chunk-QPO6NSRR.js";
|
|
8
8
|
import {
|
|
9
9
|
EMAIL_DRIVERS,
|
|
10
10
|
NotificationProviderError,
|
|
@@ -25,11 +25,11 @@ import {
|
|
|
25
25
|
smsTransport,
|
|
26
26
|
webPushTransport,
|
|
27
27
|
whatsAppTransport
|
|
28
|
-
} from "../chunk-
|
|
29
|
-
import "../chunk-
|
|
28
|
+
} from "../chunk-53SH5ABN.js";
|
|
29
|
+
import "../chunk-XFODKRRB.js";
|
|
30
30
|
import "../chunk-M2TVBVH2.js";
|
|
31
31
|
import "../chunk-RTURLH5U.js";
|
|
32
|
-
import "../chunk-
|
|
32
|
+
import "../chunk-CSMFJJXY.js";
|
|
33
33
|
import "../chunk-EKUSNUBT.js";
|
|
34
34
|
import "../chunk-7QVYU63E.js";
|
|
35
35
|
export {
|
|
@@ -181,8 +181,26 @@ interface TransportRecipient {
|
|
|
181
181
|
locale?: string | null;
|
|
182
182
|
/** Phone as the host stores it (transports normalize per provider rules). */
|
|
183
183
|
phone: string | null;
|
|
184
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* How many push subscriptions this NOTIFICATION can actually reach — not how
|
|
186
|
+
* many the user holds. Scoped by {@link TransportRecipient.clientId}.
|
|
187
|
+
*/
|
|
185
188
|
pushSubscriptionCount: number;
|
|
189
|
+
/**
|
|
190
|
+
* The store this notification belongs to, or `null` for a platform-wide one
|
|
191
|
+
*.
|
|
192
|
+
*
|
|
193
|
+
* REQUIRED, and deliberately so where optional is the tempting answer. An
|
|
194
|
+
* optional field fails OPEN: a construction site that forgets it yields
|
|
195
|
+
* `undefined`, which the push scope reads as "platform notification -> every
|
|
196
|
+
* subscription" — precisely the cross-store fan-out this exists to stop.
|
|
197
|
+
* Three construction sites is the argument FOR requiring it, not against; the
|
|
198
|
+
* compiler then names them.
|
|
199
|
+
*
|
|
200
|
+
* Not a breaking change for adopters: every external use CONSUMES a recipient
|
|
201
|
+
* through `supports`/`send` below, and the only constructors are in-package.
|
|
202
|
+
*/
|
|
203
|
+
clientId: string | null;
|
|
186
204
|
}
|
|
187
205
|
/**
|
|
188
206
|
* One pluggable channel adapter: a FORMATTER (agnostic content → channel
|
package/dist/web-push/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as WebPushSender } from '../web-push-
|
|
2
|
-
import '../types-
|
|
1
|
+
import { c as WebPushSender } from '../web-push-DUn_d_gj.js';
|
|
2
|
+
import '../types-DPiePHJD.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 { g as NotificationLogger, d as NotificationContent, j as NotificationTransport } from './types-
|
|
1
|
+
import { g as NotificationLogger, d as NotificationContent, j as NotificationTransport } from './types-DPiePHJD.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The DRIVER port — the reason a host adds a vendor with a config entry and no
|
|
@@ -133,7 +133,17 @@ interface WebPushDriverDeclaration extends DriverDeclarationBase {
|
|
|
133
133
|
}
|
|
134
134
|
/** The subscriptions the transport reads and prunes (db-backed by the mount). */
|
|
135
135
|
interface WebPushSubscriptionSource {
|
|
136
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The subscriptions one notification may reach.
|
|
138
|
+
*
|
|
139
|
+
* `notificationClientId` is the notification's tenant — `null` for a
|
|
140
|
+
* platform-wide one, which every subscription receives. An ADOPTER with a
|
|
141
|
+
* hand-written source must honour it: a function declared with fewer
|
|
142
|
+
* parameters still type-checks, so an un-updated implementation silently
|
|
143
|
+
* ignores the scope and keeps fanning out to every origin, with nothing
|
|
144
|
+
* failing to compile to say so.
|
|
145
|
+
*/
|
|
146
|
+
list(userId: string, notificationClientId?: string | null): Promise<{
|
|
137
147
|
id: string;
|
|
138
148
|
endpoint: string;
|
|
139
149
|
p256dh: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/notifications",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.14.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.",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"prisma:sync:check": "node scripts/sync-notifications-schema.mjs --check"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
|
-
"@12-apps/ui": "^6.
|
|
74
|
+
"@12-apps/ui": "^6.28.0"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"@12-apps/wiring": ">=1.3.0",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- @12-apps/notifications: which ORIGIN a browser subscription was
|
|
2
|
+
-- registered on.
|
|
3
|
+
--
|
|
4
|
+
-- A multi-tenant host installs one storefront per store as its own PWA, and a
|
|
5
|
+
-- PWA's identity is its ORIGIN — so two stores are two apps. Without this
|
|
6
|
+
-- column the Web Push transport fans every notification out to every row a user
|
|
7
|
+
-- holds, and store B's push lands inside store A's installed app wearing store
|
|
8
|
+
-- A's icon.
|
|
9
|
+
--
|
|
10
|
+
-- NULL means the PLATFORM origin: the marketplace host itself, or any adopter
|
|
11
|
+
-- that serves one origin and has no per-store apps at all. That is why the
|
|
12
|
+
-- column is nullable and why every EXISTING row reads correctly with no
|
|
13
|
+
-- backfill — an adopter who never had custom domains keeps today's behaviour
|
|
14
|
+
-- exactly, which is what makes this a MINOR release rather than a breaking one.
|
|
15
|
+
--
|
|
16
|
+
-- A by-value scalar with NO foreign key, exactly as `user_id` and
|
|
17
|
+
-- `notifications.client_id` already are here (the payments-backend doctrine at
|
|
18
|
+
-- the top of `notifications.prisma`): this package must not constrain a host's
|
|
19
|
+
-- tenant table, whose name it does not know.
|
|
20
|
+
--
|
|
21
|
+
-- Additive DDL, so no expand/contract ceremony: the previous release reads a
|
|
22
|
+
-- table that has gained a nullable column it never mentions, which is safe.
|
|
23
|
+
ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "client_id" TEXT;
|
|
24
|
+
|
|
25
|
+
-- Serves the ONE query the send path makes per notification:
|
|
26
|
+
-- user_id = $1 AND ($2 IS NULL OR client_id IS NULL OR client_id = $2)
|
|
27
|
+
-- `user_id` leads because it is the selective half and is on every read; the
|
|
28
|
+
-- existing `push_subscriptions_user_id_idx` stays for the unscoped counts the
|
|
29
|
+
-- settings screen still makes.
|
|
30
|
+
CREATE INDEX IF NOT EXISTS "push_subscriptions_user_id_client_id_idx"
|
|
31
|
+
ON "push_subscriptions" ("user_id", "client_id");
|
|
@@ -131,11 +131,23 @@ model PushSubscription {
|
|
|
131
131
|
endpoint String @unique
|
|
132
132
|
p256dh String
|
|
133
133
|
auth String
|
|
134
|
+
/// Which ORIGIN this browser registered on.
|
|
135
|
+
///
|
|
136
|
+
/// NULL is the PLATFORM origin — the host itself, or any adopter serving one
|
|
137
|
+
/// origin. A subscription stamped with a store receives that store's
|
|
138
|
+
/// notifications plus platform-wide ones (`clientId IS NULL`) and NEVER
|
|
139
|
+
/// another store's; a platform-origin subscription receives everything, which
|
|
140
|
+
/// is what keeps a marketplace host's behaviour identical to before.
|
|
141
|
+
///
|
|
142
|
+
/// Nullable so every pre-existing row reads as "platform" with no backfill.
|
|
143
|
+
clientId String? @map("client_id")
|
|
134
144
|
// Free-form browser/device hint ("Chrome · Linux") for a device list.
|
|
135
145
|
userAgent String? @map("user_agent")
|
|
136
146
|
createdAt DateTime @default(now()) @map("created_at")
|
|
137
147
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
138
148
|
|
|
139
149
|
@@index([userId])
|
|
150
|
+
// The send path's one query: owner, then the origin rule above.
|
|
151
|
+
@@index([userId, clientId])
|
|
140
152
|
@@map("push_subscriptions")
|
|
141
153
|
}
|
|
@@ -103,7 +103,7 @@ export interface WebNotifications {
|
|
|
103
103
|
* right now, and `useBellBadge` below is the door. Still the right hook for
|
|
104
104
|
* anything that genuinely wants "how many unread rows".
|
|
105
105
|
*/
|
|
106
|
-
useUnreadCount: (options?:
|
|
106
|
+
useUnreadCount: (options?: BadgeHookOptions) => number;
|
|
107
107
|
/**
|
|
108
108
|
* The badge's NUMBER AND TONE, for a host with its own trigger chrome.
|
|
109
109
|
*
|
|
@@ -119,7 +119,7 @@ export interface WebNotifications {
|
|
|
119
119
|
* hook that bell uses. Without live activities configured it is
|
|
120
120
|
* `useUnreadCount` plus `hasNew: count > 0`.
|
|
121
121
|
*/
|
|
122
|
-
useBellBadge: (options?:
|
|
122
|
+
useBellBadge: (options?: BadgeHookOptions) => BellBadge;
|
|
123
123
|
/** The shared client state, for host glue. */
|
|
124
124
|
store: InboxStore;
|
|
125
125
|
/** The bound wire client. */
|
|
@@ -128,6 +128,16 @@ export interface WebNotifications {
|
|
|
128
128
|
messages: NotificationMessages;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* What a host passes either badge hook: whether to run at all, and — for a host
|
|
133
|
+
* that holds its own realtime connection — whether that connection is up
|
|
134
|
+
* (`BadgeSyncOptions.live`).
|
|
135
|
+
*/
|
|
136
|
+
export interface BadgeHookOptions {
|
|
137
|
+
enabled?: boolean;
|
|
138
|
+
live?: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
/** What the factory passes both badge hooks: whatever realtime wiring it has. */
|
|
132
142
|
type SubscribeOption = {
|
|
133
143
|
subscribe?: NotificationsSubscribe;
|
package/src/react/hooks.ts
CHANGED
|
@@ -53,6 +53,23 @@ export interface BadgeSyncOptions {
|
|
|
53
53
|
enabled?: boolean;
|
|
54
54
|
subscribe?: NotificationsSubscribe;
|
|
55
55
|
useSignal?: NotificationsSignalHook;
|
|
56
|
+
/**
|
|
57
|
+
* The host's own word that its realtime channel is up RIGHT NOW.
|
|
58
|
+
*
|
|
59
|
+
* `subscribe` relaxes the poll because a live subscription is implied by its
|
|
60
|
+
* presence. `useSignal` cannot say the same — a hook handed a callback knows
|
|
61
|
+
* nothing about whether its connection opened — and neither can a host that
|
|
62
|
+
* wires the invalidate itself, outside this package, because its connection
|
|
63
|
+
* is session-scoped and must stay behind `enabled` (which `useSignal` is
|
|
64
|
+
* called in front of). Such a host had no way to relax the badge at all, and
|
|
65
|
+
* every signed-in reader polled once a minute on top of a working stream.
|
|
66
|
+
*
|
|
67
|
+
* `true` relaxes the poll to {@link BADGE_RECONCILE_MS}, exactly as a
|
|
68
|
+
* `subscribe` does; anything else keeps {@link BADGE_POLL_MS}. It is a STATUS,
|
|
69
|
+
* so pass it live — a stream that drops takes the badge straight back to the
|
|
70
|
+
* fast poll on the next render.
|
|
71
|
+
*/
|
|
72
|
+
live?: boolean;
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
/**
|
|
@@ -100,6 +117,9 @@ const NOTHING_TO_SHOW: InboxState = {
|
|
|
100
117
|
export function useBadgeState(store: InboxStore, options: BadgeSyncOptions = {}): InboxState {
|
|
101
118
|
const enabled = options.enabled ?? true;
|
|
102
119
|
const subscribe = options.subscribe;
|
|
120
|
+
// Pushed to by a subscription this package holds, or by one the host holds
|
|
121
|
+
// and says is up — see `BadgeSyncOptions.live`.
|
|
122
|
+
const relaxed = subscribe !== undefined || options.live === true;
|
|
103
123
|
const live = useInboxState(store);
|
|
104
124
|
const state = enabled ? live : NOTHING_TO_SHOW;
|
|
105
125
|
|
|
@@ -113,11 +133,11 @@ export function useBadgeState(store: InboxStore, options: BadgeSyncOptions = {})
|
|
|
113
133
|
if (!enabled) return;
|
|
114
134
|
store.refreshBadge();
|
|
115
135
|
const unsubscribe = subscribe?.(() => store.invalidate());
|
|
116
|
-
// A live
|
|
117
|
-
//
|
|
136
|
+
// A live channel relaxes the poll to the reconcile interval; without one it
|
|
137
|
+
// stays the 60 s poll.
|
|
118
138
|
const interval = setInterval(
|
|
119
139
|
() => store.refreshBadge(),
|
|
120
|
-
|
|
140
|
+
relaxed ? BADGE_RECONCILE_MS : BADGE_POLL_MS,
|
|
121
141
|
);
|
|
122
142
|
const onFocus = (): void => store.refreshBadge();
|
|
123
143
|
globalThis.addEventListener?.('focus', onFocus);
|
|
@@ -126,7 +146,7 @@ export function useBadgeState(store: InboxStore, options: BadgeSyncOptions = {})
|
|
|
126
146
|
globalThis.removeEventListener?.('focus', onFocus);
|
|
127
147
|
unsubscribe?.();
|
|
128
148
|
};
|
|
129
|
-
}, [store, enabled, subscribe]);
|
|
149
|
+
}, [store, enabled, subscribe, relaxed]);
|
|
130
150
|
|
|
131
151
|
return state;
|
|
132
152
|
}
|
package/src/react/index.ts
CHANGED
package/src/server/context.ts
CHANGED
|
@@ -22,6 +22,21 @@ import { NOTIFICATION_CHANNELS, type NotificationChannel } from '../types';
|
|
|
22
22
|
*/
|
|
23
23
|
export interface NotificationsActor {
|
|
24
24
|
userId: string;
|
|
25
|
+
/**
|
|
26
|
+
* The store whose ORIGIN this request arrived on, resolved by the
|
|
27
|
+
* HOST from the hostname — never read from the body or the query.
|
|
28
|
+
*
|
|
29
|
+
* The paragraph above still holds: this is not a tenant the surface
|
|
30
|
+
* authorizes against, it is a NARROWING of a read that is already the
|
|
31
|
+
* caller's own, and it can never widen one. A host with a single origin never
|
|
32
|
+
* sets it and every endpoint answers exactly what it answered before.
|
|
33
|
+
*
|
|
34
|
+
* It exists because a multi-tenant host installs one storefront per store as
|
|
35
|
+
* its own PWA, and a PWA's identity is its ORIGIN — so an inbox answering a
|
|
36
|
+
* store's own app with a neighbour's notifications is one store reporting on
|
|
37
|
+
* another.
|
|
38
|
+
*/
|
|
39
|
+
scopeClientId?: string;
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
/** One request, already authenticated and routed by the host. */
|
package/src/server/db.ts
CHANGED
|
@@ -54,14 +54,40 @@ export interface NotificationPageAfter {
|
|
|
54
54
|
id: string;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
57
|
+
/**
|
|
58
|
+
* One BRANCH of an inbox filter — a member of `AND`/`OR`, never a whole read.
|
|
59
|
+
*
|
|
60
|
+
* Split out when the store scope arrived, and the split is what keeps `deletedAt: null`
|
|
61
|
+
* REQUIRED on the read itself (see {@link NotificationWhere}). Widening `OR`
|
|
62
|
+
* from the old fixed keyset tuple to a general array needed its members to be
|
|
63
|
+
* valid filters, and the tempting move — relaxing `deletedAt` on the one type —
|
|
64
|
+
* would have removed the only thing that makes a Prisma-backed host exclude
|
|
65
|
+
* soft-deleted rows. Both in-package doubles hard-code that filter regardless,
|
|
66
|
+
* so Prisma is precisely the implementation the requirement was protecting.
|
|
67
|
+
*
|
|
68
|
+
* `createdAt` and `id: { lt }` are here because the keyset boundary is built
|
|
69
|
+
* from them (`../inbox.ts`'s `pageWhere`); without them the branch type cannot
|
|
70
|
+
* express the very thing the widening exists to preserve.
|
|
71
|
+
*/
|
|
72
|
+
export interface NotificationWhereBranch {
|
|
59
73
|
userId?: string;
|
|
60
|
-
id?: string | { in: string[] };
|
|
61
|
-
|
|
74
|
+
id?: string | { in: string[] } | { lt: string };
|
|
75
|
+
createdAt?: Date | { lt: Date };
|
|
76
|
+
deletedAt?: null;
|
|
62
77
|
readAt?: null;
|
|
63
|
-
/**
|
|
64
|
-
|
|
78
|
+
/** `null` matches the platform-wide rows that travel with every store scope. */
|
|
79
|
+
clientId?: string | null;
|
|
80
|
+
OR?: NotificationWhereBranch[];
|
|
81
|
+
AND?: NotificationWhereBranch[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The inbox read filter. `deletedAt: null` is on every read, always — and it
|
|
86
|
+
* stays REQUIRED here, which is the whole reason {@link NotificationWhereBranch}
|
|
87
|
+
* is a separate type.
|
|
88
|
+
*/
|
|
89
|
+
export interface NotificationWhere extends NotificationWhereBranch {
|
|
90
|
+
deletedAt: null;
|
|
65
91
|
}
|
|
66
92
|
|
|
67
93
|
export interface NotificationDelegate {
|
|
@@ -180,11 +206,25 @@ export interface PushSubscriptionRow {
|
|
|
180
206
|
endpoint: string;
|
|
181
207
|
p256dh: string;
|
|
182
208
|
auth: string;
|
|
209
|
+
/** The origin this browser registered on; `null` = the platform origin. */
|
|
210
|
+
clientId: string | null;
|
|
183
211
|
userAgent: string | null;
|
|
184
212
|
}
|
|
185
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Which of a user's subscriptions one send may reach.
|
|
216
|
+
*
|
|
217
|
+
* The disjunction is load-bearing and `clientId: { in: [x, null] }` is NOT a
|
|
218
|
+
* shortcut for it: SQL `IN` never matches NULL, so the platform-wide rows that
|
|
219
|
+
* must travel with every store scope would silently vanish.
|
|
220
|
+
*/
|
|
221
|
+
export interface PushSubscriptionWhere {
|
|
222
|
+
userId: string;
|
|
223
|
+
OR?: [{ clientId: null }, { clientId: string }];
|
|
224
|
+
}
|
|
225
|
+
|
|
186
226
|
export interface PushSubscriptionDelegate {
|
|
187
|
-
count(args: { where:
|
|
227
|
+
count(args: { where: PushSubscriptionWhere }): Promise<number>;
|
|
188
228
|
/**
|
|
189
229
|
* The row holding one endpoint, whoever owns it. Read BEFORE an upsert so a
|
|
190
230
|
* re-own (the same browser profile, a different signed-in user) is a logged
|
|
@@ -192,7 +232,7 @@ export interface PushSubscriptionDelegate {
|
|
|
192
232
|
* whether THIS browser's subscription is still the caller's.
|
|
193
233
|
*/
|
|
194
234
|
findUnique(args: { where: { endpoint: string } }): Promise<PushSubscriptionRow | null>;
|
|
195
|
-
findMany(args: { where:
|
|
235
|
+
findMany(args: { where: PushSubscriptionWhere }): Promise<PushSubscriptionRow[]>;
|
|
196
236
|
upsert(args: {
|
|
197
237
|
where: { endpoint: string };
|
|
198
238
|
create: {
|
|
@@ -200,12 +240,14 @@ export interface PushSubscriptionDelegate {
|
|
|
200
240
|
endpoint: string;
|
|
201
241
|
p256dh: string;
|
|
202
242
|
auth: string;
|
|
243
|
+
clientId: string | null;
|
|
203
244
|
userAgent: string | null;
|
|
204
245
|
};
|
|
205
246
|
update: {
|
|
206
247
|
userId: string;
|
|
207
248
|
p256dh: string;
|
|
208
249
|
auth: string;
|
|
250
|
+
clientId: string | null;
|
|
209
251
|
userAgent: string | null;
|
|
210
252
|
};
|
|
211
253
|
}): Promise<PushSubscriptionRow>;
|
package/src/server/dispatch.ts
CHANGED
|
@@ -92,6 +92,7 @@ export interface NotificationDispatchDeps {
|
|
|
92
92
|
export async function loadRecipient(
|
|
93
93
|
deps: NotificationDispatchDeps,
|
|
94
94
|
userId: string,
|
|
95
|
+
clientId: string | null,
|
|
95
96
|
): Promise<TransportRecipient | null> {
|
|
96
97
|
const contact = await deps.contacts.getContact(userId);
|
|
97
98
|
if (!contact) return null;
|
|
@@ -103,7 +104,11 @@ export async function loadRecipient(
|
|
|
103
104
|
// absent case has to stay distinguishable from a stated language, because
|
|
104
105
|
// that is what lets a generator apply its own default in one place.
|
|
105
106
|
...(contact.locale === undefined ? {} : { locale: contact.locale }),
|
|
106
|
-
|
|
107
|
+
clientId,
|
|
108
|
+
// SCOPED, and this is what keeps `supports()` honest: it gates on this
|
|
109
|
+
// number, so an unscoped count would enqueue a WEB_PUSH delivery for a
|
|
110
|
+
// notification no reachable subscription exists for.
|
|
111
|
+
pushSubscriptionCount: await deps.pushSubscriptions.count(userId, clientId),
|
|
107
112
|
};
|
|
108
113
|
}
|
|
109
114
|
|
|
@@ -223,7 +228,9 @@ export async function dispatchOne(
|
|
|
223
228
|
});
|
|
224
229
|
if (queued.length === 0) return 0;
|
|
225
230
|
|
|
226
|
-
|
|
231
|
+
// The STORED column, so the retry sweep scopes identically to the first
|
|
232
|
+
// attempt — anything less would let a retry leak what the first send withheld.
|
|
233
|
+
const recipient = await loadRecipient(deps, notification.userId, notification.clientId);
|
|
227
234
|
if (!recipient) {
|
|
228
235
|
await abandonUnreachable(deps, client, queued, notification.userId);
|
|
229
236
|
return 0;
|