@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.
- package/dist/{chunk-2TJ4D2KE.js → chunk-4DTUD74E.js} +2 -2
- package/dist/{chunk-WVRODNXQ.js → chunk-53SH5ABN.js} +111 -46
- package/dist/chunk-53SH5ABN.js.map +1 -0
- package/dist/{chunk-6W7INOYQ.js → chunk-CSMFJJXY.js} +1 -1
- package/dist/chunk-CSMFJJXY.js.map +1 -0
- package/dist/{chunk-ZIR3ILFH.js → chunk-QPO6NSRR.js} +2 -2
- package/dist/{chunk-SWOWHIFE.js → chunk-XFODKRRB.js} +37 -2
- package/dist/chunk-XFODKRRB.js.map +1 -0
- package/dist/{create-api-notifications-CcPYrM3p.d.ts → create-api-notifications-cIEbFqoZ.d.ts} +132 -32
- package/dist/{create-web-notifications-CnaXx6km.d.ts → create-web-notifications-BODiQRK-.d.ts} +2 -2
- package/dist/{generators-qAD4fNPq.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 +9 -3
- package/dist/{jobs--fex87-q.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 +1 -1
- package/dist/{preferences-screen-SXUIGECY.js → preferences-screen-YRPNUZS5.js} +2 -2
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +1 -1
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +4 -4
- package/dist/{types-BlqZkCWZ.d.ts → types-DPiePHJD.d.ts} +52 -1
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-Dnyaha2z.d.ts → web-push-DUn_d_gj.d.ts} +12 -2
- package/dist/{wire-BG1kuoXX.d.ts → wire-CJka1AvM.d.ts} +65 -2
- 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/index.ts +4 -0
- package/src/preferences-core.ts +107 -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/preferences.ts +29 -9
- package/src/server/push-subscriptions.ts +61 -7
- package/src/server/router.ts +42 -8
- package/src/server/routes.ts +14 -5
- package/src/server/transports/web-push.ts +15 -2
- package/src/types.ts +52 -1
- package/dist/chunk-6W7INOYQ.js.map +0 -1
- package/dist/chunk-SWOWHIFE.js.map +0 -1
- package/dist/chunk-WVRODNXQ.js.map +0 -1
- /package/dist/{chunk-2TJ4D2KE.js.map → chunk-4DTUD74E.js.map} +0 -0
- /package/dist/{chunk-ZIR3ILFH.js.map → chunk-QPO6NSRR.js.map} +0 -0
- /package/dist/{preferences-screen-SXUIGECY.js.map → preferences-screen-YRPNUZS5.js.map} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { b as NotificationCategory, c as NotificationChannel } from './types-
|
|
1
|
+
import { b as NotificationCategory, c as NotificationChannel } from './types-DPiePHJD.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Every sentence this package can say to a USER, stated by the HOST.
|
|
@@ -163,6 +163,69 @@ declare function defaultChannelMatrix(categories: readonly NotificationCategory[
|
|
|
163
163
|
* a data migration for every existing row on every channel that ever ships.
|
|
164
164
|
*/
|
|
165
165
|
declare function mergeStoredRow(stored: unknown, base: ChannelRow): ChannelRow;
|
|
166
|
+
/**
|
|
167
|
+
* What ONE notification type says about its own channels, independent of the
|
|
168
|
+
* user's category preferences: {@link NotificationGenerator.channels} (the hard
|
|
169
|
+
* availability cap) and {@link NotificationGenerator.channelDefaults} (a
|
|
170
|
+
* starting point the user can still move).
|
|
171
|
+
*
|
|
172
|
+
* Structurally what a generator already is, rather than the generator itself,
|
|
173
|
+
* so the policy here stays free of the registry and the router.
|
|
174
|
+
*/
|
|
175
|
+
interface TypeChannelRules {
|
|
176
|
+
channels?: readonly NotificationChannel[];
|
|
177
|
+
channelDefaults?: Partial<ChannelRow>;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The channels a type may EVER use, coerced onto the closed channel set.
|
|
181
|
+
*
|
|
182
|
+
* `undefined` — a generator that never declared a list — means every channel,
|
|
183
|
+
* which is what keeps every generator written before the field working. An
|
|
184
|
+
* EMPTY list means no transport channel at all and is legal: the inbox record
|
|
185
|
+
* is written by the router regardless, and the inbox is not a channel a user
|
|
186
|
+
* opts out of.
|
|
187
|
+
*
|
|
188
|
+
* Filtering through {@link NOTIFICATION_CHANNELS} rather than returning the
|
|
189
|
+
* declaration is deliberate: it drops a value that is not a channel (a typo, a
|
|
190
|
+
* channel removed from the set since) instead of carrying it into an
|
|
191
|
+
* intersection where it would silently match nothing anyway, and it fixes the
|
|
192
|
+
* order so two declarations of the same set compare equal.
|
|
193
|
+
*/
|
|
194
|
+
declare function availableChannelsOf(declared: readonly NotificationChannel[] | undefined): NotificationChannel[];
|
|
195
|
+
/**
|
|
196
|
+
* Drop the channels a type does not offer. Applied by the router AFTER every
|
|
197
|
+
* other gate, so no later stage can hand back a channel the type never offered
|
|
198
|
+
* — including the plan gate's own error fallback, which degrades to the free
|
|
199
|
+
* channels and would otherwise restore an e-mail this type had just refused.
|
|
200
|
+
*/
|
|
201
|
+
declare function capToAvailable(channels: readonly NotificationChannel[], declared: readonly NotificationChannel[] | undefined): NotificationChannel[];
|
|
202
|
+
/**
|
|
203
|
+
* The channels one (user, category, TYPE) actually enables — the whole policy
|
|
204
|
+
* in one pure function, so the router's gate can be argued about without a
|
|
205
|
+
* database.
|
|
206
|
+
*
|
|
207
|
+
* The order is the meaning:
|
|
208
|
+
* 1. the category's defaults, with the TYPE's defaults over them — a type
|
|
209
|
+
* moves the starting point;
|
|
210
|
+
* 2. the user's stored row over that — an explicit choice beats any default,
|
|
211
|
+
* which is what makes step 1 a default rather than a rule;
|
|
212
|
+
* 3. the type's AVAILABILITY over everything — a channel this type does not
|
|
213
|
+
* offer is gone even when the user's stored row explicitly asked for it,
|
|
214
|
+
* because it was never on offer for this message.
|
|
215
|
+
*
|
|
216
|
+
* Step 3 overriding a stored `true` is the one place a user's saved choice is
|
|
217
|
+
* discarded, and it is the point of the field: the answer they gave was about
|
|
218
|
+
* the CATEGORY, and a category can hold messages this channel was never right
|
|
219
|
+
* for. Step 2 beating step 1 is what keeps the other field a default.
|
|
220
|
+
*/
|
|
221
|
+
declare function resolveTypeChannels(input: {
|
|
222
|
+
/** The stored `channels` JSON for this (user, category), if any. */
|
|
223
|
+
stored?: unknown;
|
|
224
|
+
/** The category's effective default row (host defaults already merged). */
|
|
225
|
+
categoryDefaults: ChannelRow;
|
|
226
|
+
/** The type's own declarations. */
|
|
227
|
+
rules?: TypeChannelRules;
|
|
228
|
+
}): NotificationChannel[];
|
|
166
229
|
/** The channels enabled by one effective row — the router's gate. */
|
|
167
230
|
declare function enabledChannelsOf(row: ChannelRow): NotificationChannel[];
|
|
168
231
|
/**
|
|
@@ -220,4 +283,4 @@ interface NotificationRow {
|
|
|
220
283
|
/** Row → wire. Dates become ISO strings; a null `data` becomes `{}`. */
|
|
221
284
|
declare function inboxWire(row: NotificationRow): InboxNotification;
|
|
222
285
|
|
|
223
|
-
export { type ChannelMatrix as C, DEFAULT_CHANNEL_ROW as D, type InboxNotification as I, type ListNotificationsResult as L, type NotificationMessages as N, type ChannelRow as a, type NotificationRow as b, type NotificationWireMessages as c,
|
|
286
|
+
export { type ChannelMatrix as C, DEFAULT_CHANNEL_ROW as D, type InboxNotification as I, type ListNotificationsResult as L, type NotificationMessages as N, type TypeChannelRules as T, type ChannelRow as a, type NotificationRow as b, type NotificationWireMessages as c, availableChannelsOf as d, capToAvailable as e, defaultChannelMatrix as f, enabledChannelsOf as g, mergeStoredRow as h, inboxWire as i, messagesOf as j, type NotificationsCopySource as k, mergeChoices as m, resolveTypeChannels as r };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/notifications",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.13.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.27.1"
|
|
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
|
}
|
package/src/index.ts
CHANGED
|
@@ -48,13 +48,17 @@ export {
|
|
|
48
48
|
} from './generators';
|
|
49
49
|
|
|
50
50
|
export {
|
|
51
|
+
availableChannelsOf,
|
|
52
|
+
capToAvailable,
|
|
51
53
|
DEFAULT_CHANNEL_ROW,
|
|
52
54
|
defaultChannelMatrix,
|
|
53
55
|
enabledChannelsOf,
|
|
54
56
|
mergeChoices,
|
|
55
57
|
mergeStoredRow,
|
|
58
|
+
resolveTypeChannels,
|
|
56
59
|
type ChannelMatrix,
|
|
57
60
|
type ChannelRow,
|
|
61
|
+
type TypeChannelRules,
|
|
58
62
|
} from './preferences-core';
|
|
59
63
|
|
|
60
64
|
export { normalizePhoneE164, type PhoneNormalizeOptions } from './phone';
|
package/src/preferences-core.ts
CHANGED
|
@@ -70,11 +70,118 @@ export function mergeStoredRow(stored: unknown, base: ChannelRow): ChannelRow {
|
|
|
70
70
|
return row;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* What ONE notification type says about its own channels, independent of the
|
|
75
|
+
* user's category preferences: {@link NotificationGenerator.channels} (the hard
|
|
76
|
+
* availability cap) and {@link NotificationGenerator.channelDefaults} (a
|
|
77
|
+
* starting point the user can still move).
|
|
78
|
+
*
|
|
79
|
+
* Structurally what a generator already is, rather than the generator itself,
|
|
80
|
+
* so the policy here stays free of the registry and the router.
|
|
81
|
+
*/
|
|
82
|
+
export interface TypeChannelRules {
|
|
83
|
+
channels?: readonly NotificationChannel[];
|
|
84
|
+
channelDefaults?: Partial<ChannelRow>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The channels a type may EVER use, coerced onto the closed channel set.
|
|
89
|
+
*
|
|
90
|
+
* `undefined` — a generator that never declared a list — means every channel,
|
|
91
|
+
* which is what keeps every generator written before the field working. An
|
|
92
|
+
* EMPTY list means no transport channel at all and is legal: the inbox record
|
|
93
|
+
* is written by the router regardless, and the inbox is not a channel a user
|
|
94
|
+
* opts out of.
|
|
95
|
+
*
|
|
96
|
+
* Filtering through {@link NOTIFICATION_CHANNELS} rather than returning the
|
|
97
|
+
* declaration is deliberate: it drops a value that is not a channel (a typo, a
|
|
98
|
+
* channel removed from the set since) instead of carrying it into an
|
|
99
|
+
* intersection where it would silently match nothing anyway, and it fixes the
|
|
100
|
+
* order so two declarations of the same set compare equal.
|
|
101
|
+
*/
|
|
102
|
+
export function availableChannelsOf(
|
|
103
|
+
declared: readonly NotificationChannel[] | undefined,
|
|
104
|
+
): NotificationChannel[] {
|
|
105
|
+
if (!declared) return [...NOTIFICATION_CHANNELS];
|
|
106
|
+
const offered = new Set<string>(declared);
|
|
107
|
+
return NOTIFICATION_CHANNELS.filter((channel) => offered.has(channel));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Drop the channels a type does not offer. Applied by the router AFTER every
|
|
112
|
+
* other gate, so no later stage can hand back a channel the type never offered
|
|
113
|
+
* — including the plan gate's own error fallback, which degrades to the free
|
|
114
|
+
* channels and would otherwise restore an e-mail this type had just refused.
|
|
115
|
+
*/
|
|
116
|
+
export function capToAvailable(
|
|
117
|
+
channels: readonly NotificationChannel[],
|
|
118
|
+
declared: readonly NotificationChannel[] | undefined,
|
|
119
|
+
): NotificationChannel[] {
|
|
120
|
+
if (!declared) return [...channels];
|
|
121
|
+
const offered = new Set(availableChannelsOf(declared));
|
|
122
|
+
return channels.filter((channel) => offered.has(channel));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The channels one (user, category, TYPE) actually enables — the whole policy
|
|
127
|
+
* in one pure function, so the router's gate can be argued about without a
|
|
128
|
+
* database.
|
|
129
|
+
*
|
|
130
|
+
* The order is the meaning:
|
|
131
|
+
* 1. the category's defaults, with the TYPE's defaults over them — a type
|
|
132
|
+
* moves the starting point;
|
|
133
|
+
* 2. the user's stored row over that — an explicit choice beats any default,
|
|
134
|
+
* which is what makes step 1 a default rather than a rule;
|
|
135
|
+
* 3. the type's AVAILABILITY over everything — a channel this type does not
|
|
136
|
+
* offer is gone even when the user's stored row explicitly asked for it,
|
|
137
|
+
* because it was never on offer for this message.
|
|
138
|
+
*
|
|
139
|
+
* Step 3 overriding a stored `true` is the one place a user's saved choice is
|
|
140
|
+
* discarded, and it is the point of the field: the answer they gave was about
|
|
141
|
+
* the CATEGORY, and a category can hold messages this channel was never right
|
|
142
|
+
* for. Step 2 beating step 1 is what keeps the other field a default.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveTypeChannels(input: {
|
|
145
|
+
/** The stored `channels` JSON for this (user, category), if any. */
|
|
146
|
+
stored?: unknown;
|
|
147
|
+
/** The category's effective default row (host defaults already merged). */
|
|
148
|
+
categoryDefaults: ChannelRow;
|
|
149
|
+
/** The type's own declarations. */
|
|
150
|
+
rules?: TypeChannelRules;
|
|
151
|
+
}): NotificationChannel[] {
|
|
152
|
+
const { stored, categoryDefaults, rules } = input;
|
|
153
|
+
const base: ChannelRow = { ...categoryDefaults, ...rules?.channelDefaults };
|
|
154
|
+
const row = stored === undefined || stored === null ? base : mergeStoredRow(stored, base);
|
|
155
|
+
return capToAvailable(enabledChannelsOf(row), rules?.channels);
|
|
156
|
+
}
|
|
157
|
+
|
|
73
158
|
/** The channels enabled by one effective row — the router's gate. */
|
|
74
159
|
export function enabledChannelsOf(row: ChannelRow): NotificationChannel[] {
|
|
75
160
|
return NOTIFICATION_CHANNELS.filter((channel) => row[channel]);
|
|
76
161
|
}
|
|
77
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The channels a stored row states an EXPLICIT choice for, dropping everything
|
|
165
|
+
* else — the inverse of {@link mergeStoredRow}, which fills the gaps in.
|
|
166
|
+
*
|
|
167
|
+
* The package's storage model is "only explicit choices are stored; a missing
|
|
168
|
+
* key falls back to the default", and this is what lets a writer keep that
|
|
169
|
+
* promise. Merging a save onto the user's EFFECTIVE row instead turns every
|
|
170
|
+
* defaulted channel into an explicit one the moment they touch any switch, and
|
|
171
|
+
* the row can never say "no opinion" about a channel again.
|
|
172
|
+
*/
|
|
173
|
+
export function explicitChoicesOf(stored: unknown): Partial<ChannelRow> {
|
|
174
|
+
const choices: Partial<ChannelRow> = {};
|
|
175
|
+
if (stored && typeof stored === 'object') {
|
|
176
|
+
const record = stored as Record<string, unknown>;
|
|
177
|
+
for (const channel of NOTIFICATION_CHANNELS) {
|
|
178
|
+
const value = record[channel];
|
|
179
|
+
if (typeof value === 'boolean') choices[channel] = value;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return choices;
|
|
183
|
+
}
|
|
184
|
+
|
|
78
185
|
/**
|
|
79
186
|
* What a PUT writes for one category: the caller's toggles merged over the
|
|
80
187
|
* user's CURRENT effective row. A single-toggle save (how the settings UI
|
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;
|
package/src/server/inbox.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
NotificationPageAfter,
|
|
6
6
|
NotificationsDbProvider,
|
|
7
7
|
NotificationWhere,
|
|
8
|
+
NotificationWhereBranch,
|
|
8
9
|
} from './db';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -15,6 +16,29 @@ import type {
|
|
|
15
16
|
* never be resurrected by mark-read.
|
|
16
17
|
*/
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The store whose ORIGIN the caller is reading from, or absent for the platform
|
|
21
|
+
* origin.
|
|
22
|
+
*
|
|
23
|
+
* A host that installs one storefront per store as its own PWA reads this from
|
|
24
|
+
* the request's hostname; a host with one origin never sets it and every read
|
|
25
|
+
* below is exactly what it was. Set, it narrows to that store's rows PLUS the
|
|
26
|
+
* platform-wide ones (`clientId IS NULL`) — a password reset or a security
|
|
27
|
+
* notice is about the person and not about a store, so hiding it inside the
|
|
28
|
+
* only app a customer opens would be a worse failure than the leak this fixes.
|
|
29
|
+
*/
|
|
30
|
+
export type NotificationScope = string | undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `clientId IN (<scope>, NULL)`, as a filter branch — or nothing at all.
|
|
34
|
+
*
|
|
35
|
+
* A disjunction rather than an `in`, because SQL `IN` never matches NULL and
|
|
36
|
+
* the NULL rows are precisely the ones that must survive every scope.
|
|
37
|
+
*/
|
|
38
|
+
function scopeBranch(scope: NotificationScope): NotificationWhereBranch[] {
|
|
39
|
+
return scope === undefined ? [] : [{ OR: [{ clientId: scope }, { clientId: null }] }];
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
export interface ListNotificationsInput {
|
|
19
43
|
/** `unread` narrows to unread rows; default lists all non-deleted. */
|
|
20
44
|
filter?: 'all' | 'unread';
|
|
@@ -34,10 +58,27 @@ const DEFAULT_PAGE = 20;
|
|
|
34
58
|
const MAX_PAGE = 100;
|
|
35
59
|
|
|
36
60
|
export interface NotificationInboxStore {
|
|
37
|
-
list(
|
|
38
|
-
|
|
61
|
+
list(
|
|
62
|
+
userId: string,
|
|
63
|
+
input?: ListNotificationsInput,
|
|
64
|
+
scope?: NotificationScope,
|
|
65
|
+
): Promise<ListNotificationsResult>;
|
|
66
|
+
/** Scoped with `list`, or the badge and the list it sits over disagree. */
|
|
67
|
+
unreadCount(userId: string, scope?: NotificationScope): Promise<number>;
|
|
39
68
|
markRead(userId: string, ids: readonly string[]): Promise<number>;
|
|
40
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Scoped too, and this one is a WRITE.
|
|
71
|
+
*
|
|
72
|
+
* Unscoped, "mark all as read" pressed inside store A's app clears store B's
|
|
73
|
+
* unread rows everywhere — a cross-store write from the app that exists to be
|
|
74
|
+
* isolated, and strictly worse than the read leak. `markRead(ids)` and
|
|
75
|
+
* `softDelete(ids)` need nothing: their ids come from the already-scoped list.
|
|
76
|
+
*
|
|
77
|
+
* The platform-wide rows ARE cleared from any origin, and that follows from
|
|
78
|
+
* the scope rule rather than contradicting it — those rows are one person's,
|
|
79
|
+
* not one store's.
|
|
80
|
+
*/
|
|
81
|
+
markAllRead(userId: string, scope?: NotificationScope): Promise<number>;
|
|
41
82
|
softDelete(userId: string, ids: readonly string[]): Promise<number>;
|
|
42
83
|
}
|
|
43
84
|
|
|
@@ -66,27 +107,55 @@ function pageWhere(
|
|
|
66
107
|
userId: string,
|
|
67
108
|
filter: ListNotificationsInput['filter'],
|
|
68
109
|
anchor: NotificationPageAfter | undefined,
|
|
110
|
+
scope: NotificationScope,
|
|
69
111
|
): NotificationWhere {
|
|
112
|
+
// Two DISJUNCTIONS have to hold at once — the page boundary and the store
|
|
113
|
+
// scope — so they are AND-ed rather than merged. A second `OR` key on this
|
|
114
|
+
// object literal would overwrite the first, dropping either the scope or the
|
|
115
|
+
// anchor; the anchor only from page TWO onward, which is the case a cursor
|
|
116
|
+
// exists for and the case a single-page test never reaches.
|
|
117
|
+
const clauses: NotificationWhereBranch[] = [
|
|
118
|
+
// `(createdAt, id) < (anchor.createdAt, anchor.id)`, as a portable `where`.
|
|
119
|
+
...(anchor
|
|
120
|
+
? [
|
|
121
|
+
{
|
|
122
|
+
OR: [
|
|
123
|
+
{ createdAt: { lt: anchor.createdAt } },
|
|
124
|
+
{ createdAt: anchor.createdAt, id: { lt: anchor.id } },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
]
|
|
128
|
+
: []),
|
|
129
|
+
...scopeBranch(scope),
|
|
130
|
+
];
|
|
70
131
|
return {
|
|
71
132
|
userId,
|
|
72
133
|
deletedAt: null,
|
|
73
134
|
...(filter === 'unread' ? { readAt: null } : {}),
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
135
|
+
...(clauses.length > 0 ? { AND: clauses } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The unread rows this scope can see — READ by the badge and WRITTEN by "mark
|
|
141
|
+
* all". One filter for both on purpose: the count and the write have to agree
|
|
142
|
+
* about which rows are in scope, and two copies of the same object literal is
|
|
143
|
+
* how a badge ends up saying 3 over a list of 2.
|
|
144
|
+
*/
|
|
145
|
+
function unreadWhere(userId: string, scope: NotificationScope): NotificationWhere {
|
|
146
|
+
const scoped = scopeBranch(scope);
|
|
147
|
+
return {
|
|
148
|
+
userId,
|
|
149
|
+
deletedAt: null,
|
|
150
|
+
readAt: null,
|
|
151
|
+
...(scoped.length > 0 ? { AND: scoped } : {}),
|
|
83
152
|
};
|
|
84
153
|
}
|
|
85
154
|
|
|
86
155
|
export function createInboxStore(db: NotificationsDbProvider): NotificationInboxStore {
|
|
87
156
|
return {
|
|
88
157
|
/** The owner's inbox, newest first, keyset-paginated, deleted excluded. */
|
|
89
|
-
async list(userId, input = {}) {
|
|
158
|
+
async list(userId, input = {}, scope) {
|
|
90
159
|
const client = await db();
|
|
91
160
|
const limit = Math.min(Math.max(input.limit ?? DEFAULT_PAGE, 1), MAX_PAGE);
|
|
92
161
|
const anchor = input.cursor
|
|
@@ -94,7 +163,7 @@ export function createInboxStore(db: NotificationsDbProvider): NotificationInbox
|
|
|
94
163
|
: undefined;
|
|
95
164
|
if (input.cursor && !anchor) return { items: [], nextCursor: null };
|
|
96
165
|
const rows = await client.notification.findMany({
|
|
97
|
-
where: pageWhere(userId, input.filter, anchor),
|
|
166
|
+
where: pageWhere(userId, input.filter, anchor, scope),
|
|
98
167
|
// `id` tie-breaks equal timestamps so pages never skip/repeat.
|
|
99
168
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|
100
169
|
take: limit + 1,
|
|
@@ -107,9 +176,9 @@ export function createInboxStore(db: NotificationsDbProvider): NotificationInbox
|
|
|
107
176
|
},
|
|
108
177
|
|
|
109
178
|
/** Unread badge count (non-deleted, unread). */
|
|
110
|
-
async unreadCount(userId) {
|
|
179
|
+
async unreadCount(userId, scope) {
|
|
111
180
|
const client = await db();
|
|
112
|
-
return client.notification.count({ where:
|
|
181
|
+
return client.notification.count({ where: unreadWhere(userId, scope) });
|
|
113
182
|
},
|
|
114
183
|
|
|
115
184
|
/**
|
|
@@ -128,10 +197,10 @@ export function createInboxStore(db: NotificationsDbProvider): NotificationInbox
|
|
|
128
197
|
},
|
|
129
198
|
|
|
130
199
|
/** Mark every unread notification of the owner read ("mark all"). */
|
|
131
|
-
async markAllRead(userId) {
|
|
200
|
+
async markAllRead(userId, scope) {
|
|
132
201
|
const client = await db();
|
|
133
202
|
const result = await client.notification.updateMany({
|
|
134
|
-
where:
|
|
203
|
+
where: unreadWhere(userId, scope),
|
|
135
204
|
data: { readAt: new Date() },
|
|
136
205
|
});
|
|
137
206
|
return result.count;
|
package/src/server/index.ts
CHANGED
|
@@ -34,11 +34,13 @@ export type {
|
|
|
34
34
|
NotificationPreferenceDelegate,
|
|
35
35
|
NotificationPreferenceRow,
|
|
36
36
|
NotificationWhere,
|
|
37
|
+
NotificationWhereBranch,
|
|
37
38
|
NotificationsDb,
|
|
38
39
|
NotificationsDbClient,
|
|
39
40
|
NotificationsDbProvider,
|
|
40
41
|
PushSubscriptionDelegate,
|
|
41
42
|
PushSubscriptionRow,
|
|
43
|
+
PushSubscriptionWhere,
|
|
42
44
|
} from './db';
|
|
43
45
|
|
|
44
46
|
export type { ListNotificationsInput, NotificationInboxStore } from './inbox';
|