@12-apps/notifications 4.11.0 → 4.12.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-6W7INOYQ.js → chunk-CPQKKLPS.js} +1 -1
- package/dist/{chunk-6W7INOYQ.js.map → chunk-CPQKKLPS.js.map} +1 -1
- package/dist/{chunk-ZIR3ILFH.js → chunk-FBBPS2LT.js} +2 -2
- package/dist/{chunk-SWOWHIFE.js → chunk-GK6GSC2J.js} +37 -2
- package/dist/chunk-GK6GSC2J.js.map +1 -0
- package/dist/{chunk-2TJ4D2KE.js → chunk-PNY6S6WH.js} +2 -2
- package/dist/{chunk-WVRODNXQ.js → chunk-WZBX7YCE.js} +32 -15
- package/dist/{chunk-WVRODNXQ.js.map → chunk-WZBX7YCE.js.map} +1 -1
- package/dist/{create-api-notifications-CcPYrM3p.d.ts → create-api-notifications-BTudlaSC.d.ts} +15 -6
- package/dist/{create-web-notifications-CnaXx6km.d.ts → create-web-notifications-2xxbKnrW.d.ts} +2 -2
- package/dist/{generators-qAD4fNPq.d.ts → generators-CQYdJfB5.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-CaovU4GM.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-S3ZHX5LB.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-H_aFzLA0.d.ts} +33 -0
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-Dnyaha2z.d.ts → web-push-C6U-5JCV.d.ts} +1 -1
- package/dist/{wire-BG1kuoXX.d.ts → wire-Bn6aA2nL.d.ts} +65 -2
- package/package.json +2 -2
- package/src/index.ts +4 -0
- package/src/preferences-core.ts +107 -0
- package/src/server/preferences.ts +29 -9
- package/src/server/router.ts +37 -7
- package/src/types.ts +33 -0
- package/dist/chunk-SWOWHIFE.js.map +0 -1
- /package/dist/{chunk-ZIR3ILFH.js.map → chunk-FBBPS2LT.js.map} +0 -0
- /package/dist/{chunk-2TJ4D2KE.js.map → chunk-PNY6S6WH.js.map} +0 -0
- /package/dist/{preferences-screen-SXUIGECY.js.map → preferences-screen-S3ZHX5LB.js.map} +0 -0
|
@@ -1 +1 @@
|
|
|
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
|
+
{"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 * The channels this type may EVER use — AVAILABILITY, not preference.\n *\n * Absent (the default) means every channel, which is what every generator\n * written before this field keeps doing. A declared list is a hard cap the\n * router applies AFTER preferences: a user whose stored row explicitly\n * enables a channel this type does not offer still does not get it, because\n * the channel was never on offer for this message. That is the difference\n * from {@link channelDefaults} — a default is a starting point a user can\n * move, availability is the set of starting points that exist.\n *\n * It is per TYPE because the category is too coarse to say it: the three\n * comanda kitchen messages sit in `orders` next to `order.paid`, and a diner\n * three metres from the food wants a push, not correspondence, while the\n * buyer of a delivery order still wants the e-mail. Declaring `[\"WEB_PUSH\"]`\n * on the mesa messages says that without splitting the category or taking\n * `order.paid`'s e-mail away with it.\n *\n * Unknown entries are ignored and order is irrelevant — the list is coerced\n * onto {@link NOTIFICATION_CHANNELS}. An EMPTY list is legal and means no\n * transport channel at all; the inbox record is written regardless, because\n * the inbox is not a channel a user opts out of.\n */\n channels?: readonly NotificationChannel[];\n /**\n * This type's starting toggles, overriding the category's defaults for the\n * channels it names and only where the user has made NO explicit choice.\n *\n * Stored preferences still win over it — that is what makes it a default.\n * To take a channel away from a user who asked for it, declare\n * {@link channels} instead.\n */\n channelDefaults?: Partial<Record<NotificationChannel, boolean>>;\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;AA4NrE,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,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
UnknownNotificationRecipientError,
|
|
3
3
|
UnknownNotificationTypeError
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-GK6GSC2J.js";
|
|
5
5
|
import {
|
|
6
6
|
__name
|
|
7
7
|
} from "./chunk-7QVYU63E.js";
|
|
@@ -91,4 +91,4 @@ export {
|
|
|
91
91
|
NOTIFICATIONS_DRAIN_LEASE_MS,
|
|
92
92
|
NOTIFICATIONS_JOBS
|
|
93
93
|
};
|
|
94
|
-
//# sourceMappingURL=chunk-
|
|
94
|
+
//# sourceMappingURL=chunk-FBBPS2LT.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
NOTIFICATION_CHANNELS
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-CPQKKLPS.js";
|
|
4
4
|
import {
|
|
5
5
|
__name
|
|
6
6
|
} from "./chunk-7QVYU63E.js";
|
|
@@ -77,10 +77,41 @@ function mergeStoredRow(stored, base) {
|
|
|
77
77
|
return row;
|
|
78
78
|
}
|
|
79
79
|
__name(mergeStoredRow, "mergeStoredRow");
|
|
80
|
+
function availableChannelsOf(declared) {
|
|
81
|
+
if (!declared) return [...NOTIFICATION_CHANNELS];
|
|
82
|
+
const offered = new Set(declared);
|
|
83
|
+
return NOTIFICATION_CHANNELS.filter((channel) => offered.has(channel));
|
|
84
|
+
}
|
|
85
|
+
__name(availableChannelsOf, "availableChannelsOf");
|
|
86
|
+
function capToAvailable(channels, declared) {
|
|
87
|
+
if (!declared) return [...channels];
|
|
88
|
+
const offered = new Set(availableChannelsOf(declared));
|
|
89
|
+
return channels.filter((channel) => offered.has(channel));
|
|
90
|
+
}
|
|
91
|
+
__name(capToAvailable, "capToAvailable");
|
|
92
|
+
function resolveTypeChannels(input) {
|
|
93
|
+
const { stored, categoryDefaults, rules } = input;
|
|
94
|
+
const base = { ...categoryDefaults, ...rules?.channelDefaults };
|
|
95
|
+
const row = stored === void 0 || stored === null ? base : mergeStoredRow(stored, base);
|
|
96
|
+
return capToAvailable(enabledChannelsOf(row), rules?.channels);
|
|
97
|
+
}
|
|
98
|
+
__name(resolveTypeChannels, "resolveTypeChannels");
|
|
80
99
|
function enabledChannelsOf(row) {
|
|
81
100
|
return NOTIFICATION_CHANNELS.filter((channel) => row[channel]);
|
|
82
101
|
}
|
|
83
102
|
__name(enabledChannelsOf, "enabledChannelsOf");
|
|
103
|
+
function explicitChoicesOf(stored) {
|
|
104
|
+
const choices = {};
|
|
105
|
+
if (stored && typeof stored === "object") {
|
|
106
|
+
const record = stored;
|
|
107
|
+
for (const channel of NOTIFICATION_CHANNELS) {
|
|
108
|
+
const value = record[channel];
|
|
109
|
+
if (typeof value === "boolean") choices[channel] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return choices;
|
|
113
|
+
}
|
|
114
|
+
__name(explicitChoicesOf, "explicitChoicesOf");
|
|
84
115
|
function mergeChoices(current, choices) {
|
|
85
116
|
return { ...current, ...choices };
|
|
86
117
|
}
|
|
@@ -126,9 +157,13 @@ export {
|
|
|
126
157
|
DEFAULT_CHANNEL_ROW,
|
|
127
158
|
defaultChannelMatrix,
|
|
128
159
|
mergeStoredRow,
|
|
160
|
+
availableChannelsOf,
|
|
161
|
+
capToAvailable,
|
|
162
|
+
resolveTypeChannels,
|
|
129
163
|
enabledChannelsOf,
|
|
164
|
+
explicitChoicesOf,
|
|
130
165
|
mergeChoices,
|
|
131
166
|
normalizePhoneE164,
|
|
132
167
|
inboxWire
|
|
133
168
|
};
|
|
134
|
-
//# sourceMappingURL=chunk-
|
|
169
|
+
//# sourceMappingURL=chunk-GK6GSC2J.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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/**\n * What ONE notification type says about its own channels, independent of the\n * user's category preferences: {@link NotificationGenerator.channels} (the hard\n * availability cap) and {@link NotificationGenerator.channelDefaults} (a\n * starting point the user can still move).\n *\n * Structurally what a generator already is, rather than the generator itself,\n * so the policy here stays free of the registry and the router.\n */\nexport interface TypeChannelRules {\n channels?: readonly NotificationChannel[];\n channelDefaults?: Partial<ChannelRow>;\n}\n\n/**\n * The channels a type may EVER use, coerced onto the closed channel set.\n *\n * `undefined` — a generator that never declared a list — means every channel,\n * which is what keeps every generator written before the field working. An\n * EMPTY list means no transport channel at all and is legal: the inbox record\n * is written by the router regardless, and the inbox is not a channel a user\n * opts out of.\n *\n * Filtering through {@link NOTIFICATION_CHANNELS} rather than returning the\n * declaration is deliberate: it drops a value that is not a channel (a typo, a\n * channel removed from the set since) instead of carrying it into an\n * intersection where it would silently match nothing anyway, and it fixes the\n * order so two declarations of the same set compare equal.\n */\nexport function availableChannelsOf(\n declared: readonly NotificationChannel[] | undefined,\n): NotificationChannel[] {\n if (!declared) return [...NOTIFICATION_CHANNELS];\n const offered = new Set<string>(declared);\n return NOTIFICATION_CHANNELS.filter((channel) => offered.has(channel));\n}\n\n/**\n * Drop the channels a type does not offer. Applied by the router AFTER every\n * other gate, so no later stage can hand back a channel the type never offered\n * — including the plan gate's own error fallback, which degrades to the free\n * channels and would otherwise restore an e-mail this type had just refused.\n */\nexport function capToAvailable(\n channels: readonly NotificationChannel[],\n declared: readonly NotificationChannel[] | undefined,\n): NotificationChannel[] {\n if (!declared) return [...channels];\n const offered = new Set(availableChannelsOf(declared));\n return channels.filter((channel) => offered.has(channel));\n}\n\n/**\n * The channels one (user, category, TYPE) actually enables — the whole policy\n * in one pure function, so the router's gate can be argued about without a\n * database.\n *\n * The order is the meaning:\n * 1. the category's defaults, with the TYPE's defaults over them — a type\n * moves the starting point;\n * 2. the user's stored row over that — an explicit choice beats any default,\n * which is what makes step 1 a default rather than a rule;\n * 3. the type's AVAILABILITY over everything — a channel this type does not\n * offer is gone even when the user's stored row explicitly asked for it,\n * because it was never on offer for this message.\n *\n * Step 3 overriding a stored `true` is the one place a user's saved choice is\n * discarded, and it is the point of the field: the answer they gave was about\n * the CATEGORY, and a category can hold messages this channel was never right\n * for. Step 2 beating step 1 is what keeps the other field a default.\n */\nexport function resolveTypeChannels(input: {\n /** The stored `channels` JSON for this (user, category), if any. */\n stored?: unknown;\n /** The category's effective default row (host defaults already merged). */\n categoryDefaults: ChannelRow;\n /** The type's own declarations. */\n rules?: TypeChannelRules;\n}): NotificationChannel[] {\n const { stored, categoryDefaults, rules } = input;\n const base: ChannelRow = { ...categoryDefaults, ...rules?.channelDefaults };\n const row = stored === undefined || stored === null ? base : mergeStoredRow(stored, base);\n return capToAvailable(enabledChannelsOf(row), rules?.channels);\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 * The channels a stored row states an EXPLICIT choice for, dropping everything\n * else — the inverse of {@link mergeStoredRow}, which fills the gaps in.\n *\n * The package's storage model is \"only explicit choices are stored; a missing\n * key falls back to the default\", and this is what lets a writer keep that\n * promise. Merging a save onto the user's EFFECTIVE row instead turns every\n * defaulted channel into an explicit one the moment they touch any switch, and\n * the row can never say \"no opinion\" about a channel again.\n */\nexport function explicitChoicesOf(stored: unknown): Partial<ChannelRow> {\n const choices: Partial<ChannelRow> = {};\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') choices[channel] = value;\n }\n }\n return choices;\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":[]}
|
|
@@ -306,7 +306,7 @@ import { Suspense as Suspense2, lazy as lazy2 } from "react";
|
|
|
306
306
|
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
307
307
|
function lazyPreferencesPage(parts) {
|
|
308
308
|
const Bound = lazy2(async () => {
|
|
309
|
-
const { PreferencesScreen } = await import("./preferences-screen-
|
|
309
|
+
const { PreferencesScreen } = await import("./preferences-screen-S3ZHX5LB.js");
|
|
310
310
|
return {
|
|
311
311
|
default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx3(PreferencesScreen, { ...props, ...parts }), "default")
|
|
312
312
|
};
|
|
@@ -395,4 +395,4 @@ export {
|
|
|
395
395
|
httpNotificationsTransport,
|
|
396
396
|
createWebNotifications
|
|
397
397
|
};
|
|
398
|
-
//# sourceMappingURL=chunk-
|
|
398
|
+
//# sourceMappingURL=chunk-PNY6S6WH.js.map
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DEFAULT_CHANNEL_ROW,
|
|
3
3
|
UnknownNotificationRecipientError,
|
|
4
|
+
capToAvailable,
|
|
4
5
|
createGeneratorRegistry,
|
|
5
6
|
enabledChannelsOf,
|
|
7
|
+
explicitChoicesOf,
|
|
6
8
|
inboxWire,
|
|
7
|
-
mergeChoices,
|
|
8
9
|
mergeStoredRow,
|
|
9
|
-
normalizePhoneE164
|
|
10
|
-
|
|
10
|
+
normalizePhoneE164,
|
|
11
|
+
resolveTypeChannels
|
|
12
|
+
} from "./chunk-GK6GSC2J.js";
|
|
11
13
|
import {
|
|
12
14
|
messagesOf
|
|
13
15
|
} from "./chunk-M2TVBVH2.js";
|
|
@@ -17,7 +19,7 @@ import {
|
|
|
17
19
|
import {
|
|
18
20
|
NOTIFICATION_CHANNELS,
|
|
19
21
|
taxonomyOf
|
|
20
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-CPQKKLPS.js";
|
|
21
23
|
import {
|
|
22
24
|
renderEmail
|
|
23
25
|
} from "./chunk-EKUSNUBT.js";
|
|
@@ -920,6 +922,14 @@ function createPreferenceStore(db, taxonomy, channelDefaults = {}) {
|
|
|
920
922
|
* A category outside the taxonomy is IGNORED rather than stored: the DB
|
|
921
923
|
* CHECK would reject it anyway, and a 500 from a stale client's extra key
|
|
922
924
|
* would fail the whole save including the toggle the user did flip.
|
|
925
|
+
*
|
|
926
|
+
* What is written is the user's EXPLICIT choices only, never their
|
|
927
|
+
* effective row. Merging onto the effective row wrote a boolean for all
|
|
928
|
+
* four channels the moment anyone touched any switch, so the row could
|
|
929
|
+
* never again say "no opinion" about a channel — which silently disabled
|
|
930
|
+
* every per-type and per-host default for that user, and defeated the
|
|
931
|
+
* missing-key fallback that lets a new channel ship without a data
|
|
932
|
+
* migration.
|
|
923
933
|
*/
|
|
924
934
|
async save(userId, input) {
|
|
925
935
|
const client = await db();
|
|
@@ -928,8 +938,7 @@ function createPreferenceStore(db, taxonomy, channelDefaults = {}) {
|
|
|
928
938
|
const existing = await client.notificationPreference.findUnique({
|
|
929
939
|
where: { userId_category: { userId, category } }
|
|
930
940
|
});
|
|
931
|
-
const
|
|
932
|
-
const channels = mergeChoices(current, choices);
|
|
941
|
+
const channels = { ...explicitChoicesOf(existing?.channels), ...choices };
|
|
933
942
|
await client.notificationPreference.upsert({
|
|
934
943
|
where: { userId_category: { userId, category } },
|
|
935
944
|
create: { userId, category, channels },
|
|
@@ -937,14 +946,16 @@ function createPreferenceStore(db, taxonomy, channelDefaults = {}) {
|
|
|
937
946
|
});
|
|
938
947
|
}
|
|
939
948
|
},
|
|
940
|
-
async enabledChannels(userId, category) {
|
|
949
|
+
async enabledChannels(userId, category, rules) {
|
|
941
950
|
const client = await db();
|
|
942
951
|
const row = await client.notificationPreference.findUnique({
|
|
943
952
|
where: { userId_category: { userId, category } }
|
|
944
953
|
});
|
|
945
|
-
return
|
|
946
|
-
|
|
947
|
-
|
|
954
|
+
return resolveTypeChannels({
|
|
955
|
+
stored: row?.channels,
|
|
956
|
+
categoryDefaults: defaultRow,
|
|
957
|
+
rules
|
|
958
|
+
});
|
|
948
959
|
}
|
|
949
960
|
};
|
|
950
961
|
}
|
|
@@ -1189,13 +1200,19 @@ function announce(deps, notification) {
|
|
|
1189
1200
|
}
|
|
1190
1201
|
}
|
|
1191
1202
|
__name(announce, "announce");
|
|
1192
|
-
async function resolveChannels(deps, event,
|
|
1193
|
-
const
|
|
1203
|
+
async function resolveChannels(deps, event, generator, recipient) {
|
|
1204
|
+
const rules = { channels: generator.channels, channelDefaults: generator.channelDefaults };
|
|
1205
|
+
const enabled = await deps.preferences.enabledChannels(
|
|
1206
|
+
event.recipient.userId,
|
|
1207
|
+
generator.category,
|
|
1208
|
+
rules
|
|
1209
|
+
);
|
|
1194
1210
|
const supported = enabled.filter((channel) => {
|
|
1195
1211
|
const transport = deps.transports.get(channel);
|
|
1196
1212
|
return transport !== null && transport.supports(recipient);
|
|
1197
1213
|
});
|
|
1198
|
-
|
|
1214
|
+
const permitted = await applyPolicy(deps, event.recipient.clientId, supported);
|
|
1215
|
+
return capToAvailable(permitted, generator.channels);
|
|
1199
1216
|
}
|
|
1200
1217
|
__name(resolveChannels, "resolveChannels");
|
|
1201
1218
|
async function commit(deps, event, category, content, channels) {
|
|
@@ -1235,7 +1252,7 @@ function createNotificationRouter(deps) {
|
|
|
1235
1252
|
const recipient = await loadRecipient(deps, event.recipient.userId);
|
|
1236
1253
|
if (!recipient) throw new UnknownNotificationRecipientError(event.recipient.userId);
|
|
1237
1254
|
const content = generator.generate(event.payload, { locale: recipient.locale });
|
|
1238
|
-
const channels = await resolveChannels(deps, event, generator
|
|
1255
|
+
const channels = await resolveChannels(deps, event, generator, recipient);
|
|
1239
1256
|
const notification = await commit(deps, event, generator.category, content, channels);
|
|
1240
1257
|
announce(deps, {
|
|
1241
1258
|
notificationId: notification.id,
|
|
@@ -1353,4 +1370,4 @@ export {
|
|
|
1353
1370
|
createTransportRegistry,
|
|
1354
1371
|
createApiNotifications
|
|
1355
1372
|
};
|
|
1356
|
-
//# sourceMappingURL=chunk-
|
|
1373
|
+
//# sourceMappingURL=chunk-WZBX7YCE.js.map
|