@12-apps/notifications 4.0.0 → 4.1.1
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-4PSUZ7X5.js +1320 -0
- package/dist/chunk-4PSUZ7X5.js.map +1 -0
- package/dist/chunk-4TTYQVPK.js +29 -0
- package/dist/chunk-4TTYQVPK.js.map +1 -0
- package/dist/chunk-7QVYU63E.js +7 -0
- package/dist/chunk-7QVYU63E.js.map +1 -0
- package/dist/chunk-AHNRSA6U.js +134 -0
- package/dist/chunk-AHNRSA6U.js.map +1 -0
- package/dist/generators-FATT537X.d.ts +26 -0
- package/dist/hono/index.d.ts +44 -0
- package/dist/hono/index.js +60 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +386 -0
- package/dist/react/index.js +1030 -0
- package/dist/react/index.js.map +1 -0
- package/dist/server/index.d.ts +905 -0
- package/dist/server/index.js +46 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types-yq_o4N01.d.ts +155 -0
- package/dist/web-push/index.d.ts +52 -0
- package/dist/web-push/index.js +25 -0
- package/dist/web-push/index.js.map +1 -0
- package/dist/web-push-KLY6UMRT.d.ts +128 -0
- package/dist/wire-SDUtscGu.d.ts +197 -0
- package/package.json +25 -7
- package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EMAIL_DRIVERS,
|
|
3
|
+
NotificationProviderError,
|
|
4
|
+
NotificationsApiError,
|
|
5
|
+
SMS_DRIVERS,
|
|
6
|
+
WEB_PUSH_DRIVERS,
|
|
7
|
+
WHATSAPP_DRIVERS,
|
|
8
|
+
absoluteLink,
|
|
9
|
+
createApiNotifications,
|
|
10
|
+
createTransportRegistry,
|
|
11
|
+
emailTransport,
|
|
12
|
+
foldApiError,
|
|
13
|
+
formatEmail,
|
|
14
|
+
formatSms,
|
|
15
|
+
formatWebPush,
|
|
16
|
+
formatWhatsApp,
|
|
17
|
+
ok,
|
|
18
|
+
smsTransport,
|
|
19
|
+
webPushTransport,
|
|
20
|
+
whatsAppTransport
|
|
21
|
+
} from "../chunk-4PSUZ7X5.js";
|
|
22
|
+
import "../chunk-AHNRSA6U.js";
|
|
23
|
+
import "../chunk-4TTYQVPK.js";
|
|
24
|
+
import "../chunk-7QVYU63E.js";
|
|
25
|
+
export {
|
|
26
|
+
EMAIL_DRIVERS,
|
|
27
|
+
NotificationProviderError,
|
|
28
|
+
NotificationsApiError,
|
|
29
|
+
SMS_DRIVERS,
|
|
30
|
+
WEB_PUSH_DRIVERS,
|
|
31
|
+
WHATSAPP_DRIVERS,
|
|
32
|
+
absoluteLink,
|
|
33
|
+
createApiNotifications,
|
|
34
|
+
createTransportRegistry,
|
|
35
|
+
emailTransport,
|
|
36
|
+
foldApiError,
|
|
37
|
+
formatEmail,
|
|
38
|
+
formatSms,
|
|
39
|
+
formatWebPush,
|
|
40
|
+
formatWhatsApp,
|
|
41
|
+
ok,
|
|
42
|
+
smsTransport,
|
|
43
|
+
webPushTransport,
|
|
44
|
+
whatsAppTransport
|
|
45
|
+
};
|
|
46
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types of the channel-agnostic notification system (12-15).
|
|
3
|
+
*
|
|
4
|
+
* Three decoupled layers, each open for extension without touching the others:
|
|
5
|
+
* - GENERATORS map a typed domain event to agnostic content (title/body/…).
|
|
6
|
+
* - The CHANNEL ROUTER always writes the notification-centre inbox record,
|
|
7
|
+
* then fans out one delivery per enabled channel.
|
|
8
|
+
* - TRANSPORTS format the agnostic content for one channel and send it.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here knows about a concrete channel's wire format — that lives
|
|
11
|
+
* entirely inside each transport adapter — and nothing here knows about a
|
|
12
|
+
* concrete DOMAIN either: the event `type` set, the preference categories and
|
|
13
|
+
* the channel list are all host config (see {@link NotificationTaxonomy}).
|
|
14
|
+
*/
|
|
15
|
+
/** Transport channels a notification can fan out to (DB CHECK mirrors this). */
|
|
16
|
+
declare const NOTIFICATION_CHANNELS: readonly ["EMAIL", "SMS", "WHATSAPP", "WEB_PUSH"];
|
|
17
|
+
type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];
|
|
18
|
+
/**
|
|
19
|
+
* The preference categories are the HOST's, and required.
|
|
20
|
+
*
|
|
21
|
+
* There used to be a `NOTIFICATION_CATEGORIES = ['orders','payments','stock',
|
|
22
|
+
* 'system']` here — one product's set — and `taxonomyOf` fell back to it
|
|
23
|
+
* whenever a host passed none. The docstring argued the case itself: "it is
|
|
24
|
+
* product vocabulary, not machinery", and then shipped the vocabulary anyway as
|
|
25
|
+
* the default, which is the only part a forgetful host would ever see.
|
|
26
|
+
*
|
|
27
|
+
* The consequence was quiet rather than loud: the settings screen renders four
|
|
28
|
+
* rows a foreign host never chose, its own categories are absent, and every
|
|
29
|
+
* preference a user sets is filed against a taxonomy nothing else in that
|
|
30
|
+
* system uses. Nothing throws, because `category` is deliberately a free string
|
|
31
|
+
* — the packaged migration puts **no CHECK** on it, precisely because a closed
|
|
32
|
+
* set would be wrong for every host but the first. That freedom is what made
|
|
33
|
+
* the default undetectable.
|
|
34
|
+
*
|
|
35
|
+
* `channel` and `status` are different and keep their CHECKs: those ARE this
|
|
36
|
+
* library's own closed sets.
|
|
37
|
+
*/
|
|
38
|
+
type NotificationCategory = string;
|
|
39
|
+
/**
|
|
40
|
+
* Per-channel delivery lifecycle (DB CHECK mirrors this).
|
|
41
|
+
*
|
|
42
|
+
* `SENDING` is the CLAIM: exactly one dispatcher moves a row out of `QUEUED`,
|
|
43
|
+
* so two dispatchers can never both send the same delivery. A row left
|
|
44
|
+
* `SENDING` is a dispatcher that died mid-send, and the sweep reclaims it once
|
|
45
|
+
* it is older than the cutoff.
|
|
46
|
+
*
|
|
47
|
+
* `DEAD` is terminal: the attempt ceiling was reached (or the recipient no
|
|
48
|
+
* longer exists), and no sweep will pick the row up again. Without it a
|
|
49
|
+
* permanently invalid destination is a billed provider call on every sweep,
|
|
50
|
+
* forever, and the sweep's working set only grows.
|
|
51
|
+
*/
|
|
52
|
+
type DeliveryStatus = 'QUEUED' | 'SENDING' | 'SENT' | 'FAILED' | 'DEAD';
|
|
53
|
+
/**
|
|
54
|
+
* Channel-agnostic content a generator produces. This is what the inbox stores
|
|
55
|
+
* verbatim and what every transport's formatter receives — no channel may leak
|
|
56
|
+
* its wire format into it.
|
|
57
|
+
*/
|
|
58
|
+
interface NotificationContent {
|
|
59
|
+
title: string;
|
|
60
|
+
body: string;
|
|
61
|
+
/** In-app deep link (a same-origin path such as `/orders/123`). */
|
|
62
|
+
link?: string;
|
|
63
|
+
/** Structured extras for consumers that want more than text. */
|
|
64
|
+
data?: Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
/** Who receives a notification. `clientId` scopes it to a tenant when set. */
|
|
67
|
+
interface NotificationRecipient {
|
|
68
|
+
userId: string;
|
|
69
|
+
clientId?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A typed domain event handed to `notify`. `type` selects the registered
|
|
73
|
+
* generator; `payload` is that generator's typed input. Callers never touch
|
|
74
|
+
* channels, formatting, or preferences.
|
|
75
|
+
*/
|
|
76
|
+
interface NotificationEvent<TPayload = unknown> {
|
|
77
|
+
type: string;
|
|
78
|
+
recipient: NotificationRecipient;
|
|
79
|
+
payload: TPayload;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Maps one domain event type to agnostic content. Registered through the
|
|
83
|
+
* server config (or `registerGenerator` for a late arrival); adding a
|
|
84
|
+
* generator never touches existing generators, the router, or any transport
|
|
85
|
+
* (open/closed).
|
|
86
|
+
*/
|
|
87
|
+
interface NotificationGenerator<TPayload = unknown> {
|
|
88
|
+
/** The event key, dot-namespaced ("order.paid"). One generator per type. */
|
|
89
|
+
type: string;
|
|
90
|
+
/** The preference category the router gates this type's fan-out on. */
|
|
91
|
+
category: NotificationCategory;
|
|
92
|
+
generate: (payload: TPayload) => NotificationContent;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The recipient as a transport sees them: resolved destinations only. Built by
|
|
96
|
+
* the router from the host's contact directory + the push subscriptions this
|
|
97
|
+
* package owns; transports use it to answer
|
|
98
|
+
* {@link NotificationTransport.supports}.
|
|
99
|
+
*/
|
|
100
|
+
interface TransportRecipient {
|
|
101
|
+
userId: string;
|
|
102
|
+
email: string | null;
|
|
103
|
+
/** Phone as the host stores it (transports normalize per provider rules). */
|
|
104
|
+
phone: string | null;
|
|
105
|
+
/** How many active browser push subscriptions the user holds. */
|
|
106
|
+
pushSubscriptionCount: number;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* One pluggable channel adapter: a FORMATTER (agnostic content → channel
|
|
110
|
+
* message) plus a SENDER. Adding a channel = registering one of these; the
|
|
111
|
+
* router dispatches through the registry and needs no change.
|
|
112
|
+
*
|
|
113
|
+
* `send` resolves on success and THROWS on failure — the router records the
|
|
114
|
+
* error on the delivery row and isolates it from other channels. Sends must be
|
|
115
|
+
* retry-safe: the router may re-dispatch a QUEUED/FAILED delivery.
|
|
116
|
+
*/
|
|
117
|
+
interface NotificationTransport<TMessage = unknown> {
|
|
118
|
+
channel: NotificationChannel;
|
|
119
|
+
/**
|
|
120
|
+
* Whether this recipient is addressable on this channel right now — the
|
|
121
|
+
* destination exists (e-mail / phone / push subscription) AND the provider
|
|
122
|
+
* is configured. `false` simply skips the channel (no delivery row).
|
|
123
|
+
*/
|
|
124
|
+
supports(recipient: TransportRecipient): boolean;
|
|
125
|
+
/** Transform the agnostic content into this channel's message shape. */
|
|
126
|
+
format(content: NotificationContent): TMessage;
|
|
127
|
+
/** Deliver the formatted message to the recipient. Throws on failure. */
|
|
128
|
+
send(message: TMessage, recipient: TransportRecipient): Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The host's product vocabulary. Everything below the surface (routing,
|
|
132
|
+
* delivery rows, retries, the wire) is identical for every host; WHICH
|
|
133
|
+
* categories exist and how they are labelled is not.
|
|
134
|
+
*/
|
|
135
|
+
interface NotificationTaxonomy {
|
|
136
|
+
/** The preference categories, in the order the settings screen lists them. */
|
|
137
|
+
categories: readonly NotificationCategory[];
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The taxonomy in force. `categories` is REQUIRED — see above.
|
|
141
|
+
*
|
|
142
|
+
* The empty check was already here and stays: an empty list and a missing one
|
|
143
|
+
* are the same mistake, and both now fail at assembly rather than rendering an
|
|
144
|
+
* empty settings screen or somebody else's four rows.
|
|
145
|
+
*/
|
|
146
|
+
declare function taxonomyOf(config: {
|
|
147
|
+
categories: readonly NotificationCategory[];
|
|
148
|
+
}): NotificationTaxonomy;
|
|
149
|
+
/** The host's logger. Defaults to the console (the @12-apps/jobs precedent). */
|
|
150
|
+
interface NotificationLogger {
|
|
151
|
+
info(message: string, ...meta: unknown[]): void;
|
|
152
|
+
error(message: string, ...meta: unknown[]): void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export { type DeliveryStatus as D, type NotificationGenerator as N, type TransportRecipient as T, NOTIFICATION_CHANNELS as a, type NotificationCategory as b, type NotificationChannel as c, type NotificationContent as d, type NotificationEvent as e, type NotificationLogger as f, type NotificationRecipient as g, type NotificationTaxonomy as h, type NotificationTransport as i, taxonomyOf as t };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { W as WebPushSender } from '../web-push-KLY6UMRT.js';
|
|
2
|
+
import '../types-yq_o4N01.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `@12-apps/notifications/web-push` — the VAPID sender, behind its own subpath.
|
|
6
|
+
*
|
|
7
|
+
* VAPID signing and RFC 8291 payload encryption need the `web-push` package: a
|
|
8
|
+
* node-only dependency with its own crypto surface, which a host that never
|
|
9
|
+
* turns the channel on must not be made to install. So it is an OPTIONAL peer
|
|
10
|
+
* reached only through this subpath — the same arrangement `./hono` has, and the
|
|
11
|
+
* same reason `@12-apps/payments-backend` keeps its adapters off its root entry.
|
|
12
|
+
* Neither the root entry nor `./server` imports this file, so a bundle that
|
|
13
|
+
* never mentions web push never resolves `web-push`.
|
|
14
|
+
*
|
|
15
|
+
* The host's whole wiring:
|
|
16
|
+
*
|
|
17
|
+
* import { vapidPushSender } from '@12-apps/notifications/web-push';
|
|
18
|
+
*
|
|
19
|
+
* transports: [
|
|
20
|
+
* {
|
|
21
|
+
* channel: 'WEB_PUSH',
|
|
22
|
+
* driver: 'vapid',
|
|
23
|
+
* publicKey: env.VAPID_PUBLIC_KEY,
|
|
24
|
+
* sender: vapidPushSender({
|
|
25
|
+
* subject: env.VAPID_SUBJECT,
|
|
26
|
+
* publicKey: env.VAPID_PUBLIC_KEY,
|
|
27
|
+
* privateKey: env.VAPID_PRIVATE_KEY,
|
|
28
|
+
* }),
|
|
29
|
+
* },
|
|
30
|
+
* ]
|
|
31
|
+
*
|
|
32
|
+
* Generate the key pair once with `npx web-push generate-vapid-keys`; `subject`
|
|
33
|
+
* is the `mailto:` or https contact URL the push services require.
|
|
34
|
+
*/
|
|
35
|
+
interface VapidPushSenderConfig {
|
|
36
|
+
/** `mailto:ops@example.com` or an https contact URL. */
|
|
37
|
+
subject: string;
|
|
38
|
+
publicKey: string;
|
|
39
|
+
privateKey: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A signer for the WEB_PUSH transport.
|
|
43
|
+
*
|
|
44
|
+
* Errors pass through UNWRAPPED, and that is load-bearing: `web-push` rejects
|
|
45
|
+
* with an error carrying `statusCode`, and the transport reads it to tell a GONE
|
|
46
|
+
* subscription (404/410, prune it) from a transient failure (keep it, record the
|
|
47
|
+
* error, retry on the next sweep). Wrapping the error would turn the prune into
|
|
48
|
+
* a no-op and let dead subscriptions accumulate forever.
|
|
49
|
+
*/
|
|
50
|
+
declare function vapidPushSender(config: VapidPushSenderConfig): WebPushSender;
|
|
51
|
+
|
|
52
|
+
export { type VapidPushSenderConfig, vapidPushSender };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "../chunk-7QVYU63E.js";
|
|
4
|
+
|
|
5
|
+
// src/web-push/index.ts
|
|
6
|
+
import webpush from "web-push";
|
|
7
|
+
function vapidPushSender(config) {
|
|
8
|
+
if (!config.subject || !config.publicKey || !config.privateKey) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"vapidPushSender() needs `subject`, `publicKey` and `privateKey` \u2014 a partially configured signer would fail on the first real send instead of at boot."
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return async (subscription, payload) => {
|
|
14
|
+
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
|
|
15
|
+
await webpush.sendNotification(
|
|
16
|
+
{ endpoint: subscription.endpoint, keys: subscription.keys },
|
|
17
|
+
payload
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
__name(vapidPushSender, "vapidPushSender");
|
|
22
|
+
export {
|
|
23
|
+
vapidPushSender
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/web-push/index.ts"],"sourcesContent":["import webpush from 'web-push';\n\nimport type { WebPushSender, WebPushSubscription } from '../server/transports/web-push';\n\n/**\n * `@12-apps/notifications/web-push` — the VAPID sender, behind its own subpath.\n *\n * VAPID signing and RFC 8291 payload encryption need the `web-push` package: a\n * node-only dependency with its own crypto surface, which a host that never\n * turns the channel on must not be made to install. So it is an OPTIONAL peer\n * reached only through this subpath — the same arrangement `./hono` has, and the\n * same reason `@12-apps/payments-backend` keeps its adapters off its root entry.\n * Neither the root entry nor `./server` imports this file, so a bundle that\n * never mentions web push never resolves `web-push`.\n *\n * The host's whole wiring:\n *\n * import { vapidPushSender } from '@12-apps/notifications/web-push';\n *\n * transports: [\n * {\n * channel: 'WEB_PUSH',\n * driver: 'vapid',\n * publicKey: env.VAPID_PUBLIC_KEY,\n * sender: vapidPushSender({\n * subject: env.VAPID_SUBJECT,\n * publicKey: env.VAPID_PUBLIC_KEY,\n * privateKey: env.VAPID_PRIVATE_KEY,\n * }),\n * },\n * ]\n *\n * Generate the key pair once with `npx web-push generate-vapid-keys`; `subject`\n * is the `mailto:` or https contact URL the push services require.\n */\n\nexport interface VapidPushSenderConfig {\n /** `mailto:ops@example.com` or an https contact URL. */\n subject: string;\n publicKey: string;\n privateKey: string;\n}\n\n/**\n * A signer for the WEB_PUSH transport.\n *\n * Errors pass through UNWRAPPED, and that is load-bearing: `web-push` rejects\n * with an error carrying `statusCode`, and the transport reads it to tell a GONE\n * subscription (404/410, prune it) from a transient failure (keep it, record the\n * error, retry on the next sweep). Wrapping the error would turn the prune into\n * a no-op and let dead subscriptions accumulate forever.\n */\nexport function vapidPushSender(config: VapidPushSenderConfig): WebPushSender {\n if (!config.subject || !config.publicKey || !config.privateKey) {\n throw new Error(\n 'vapidPushSender() needs `subject`, `publicKey` and `privateKey` — a partially ' +\n 'configured signer would fail on the first real send instead of at boot.',\n );\n }\n return async (subscription: WebPushSubscription, payload: string): Promise<void> => {\n // Set per send rather than once at module load: a process may hold two\n // mounts (a platform sender and a tenant sender), and `web-push` keeps the\n // details in module state.\n webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);\n await webpush.sendNotification(\n { endpoint: subscription.endpoint, keys: subscription.keys },\n payload,\n );\n };\n}\n"],"mappings":";;;;;AAAA,OAAO,aAAa;AAoDb,SAAS,gBAAgB,QAA8C;AAC5E,MAAI,CAAC,OAAO,WAAW,CAAC,OAAO,aAAa,CAAC,OAAO,YAAY;AAC9D,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO,OAAO,cAAmC,YAAmC;AAIlF,YAAQ,gBAAgB,OAAO,SAAS,OAAO,WAAW,OAAO,UAAU;AAC3E,UAAM,QAAQ;AAAA,MACZ,EAAE,UAAU,aAAa,UAAU,MAAM,aAAa,KAAK;AAAA,MAC3D;AAAA,IACF;AAAA,EACF;AACF;AAjBgB;","names":[]}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { f as NotificationLogger, d as NotificationContent, i as NotificationTransport } from './types-yq_o4N01.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The DRIVER port — the reason a host adds a vendor with a config entry and no
|
|
5
|
+
* code (12-15).
|
|
6
|
+
*
|
|
7
|
+
* the origin's transports each read `process.env` directly:
|
|
8
|
+
* `NOTIFICATIONS_EMAIL_PROVIDER=resend` plus `RESEND_API_KEY`, and a second
|
|
9
|
+
* vendor meant editing the package. That is exactly backwards for a published
|
|
10
|
+
* package — it cannot know a host's variable names, and it must not be the
|
|
11
|
+
* thing that decides whether a channel is on. So a channel is configured by
|
|
12
|
+
* DECLARATION:
|
|
13
|
+
*
|
|
14
|
+
* transports: [
|
|
15
|
+
* { channel: 'EMAIL', driver: 'resend', apiKey, from },
|
|
16
|
+
* { channel: 'SMS', driver: 'log' },
|
|
17
|
+
* ]
|
|
18
|
+
*
|
|
19
|
+
* A channel with no declaration reports `supports() === false` and the router
|
|
20
|
+
* skips it: no delivery row, nothing fake-sent. A second vendor is one more
|
|
21
|
+
* entry in the built-in driver table (or `drivers` on the config, for a host's
|
|
22
|
+
* own), and the router, the registry and the other transports are untouched.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Every HTTP call a built-in driver makes goes through this, so a test can. */
|
|
26
|
+
type FetchImpl = (input: string, init?: {
|
|
27
|
+
method?: string;
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
body?: string;
|
|
30
|
+
}) => Promise<{
|
|
31
|
+
ok: boolean;
|
|
32
|
+
status: number;
|
|
33
|
+
text(): Promise<string>;
|
|
34
|
+
}>;
|
|
35
|
+
/** Shared by every declaration: which channel, which vendor. */
|
|
36
|
+
interface DriverDeclarationBase {
|
|
37
|
+
/** Vendor key: `resend` / `twilio` / `meta` / `log`, or a host's own. */
|
|
38
|
+
driver: string;
|
|
39
|
+
/**
|
|
40
|
+
* The HTTP client the vendor call uses. Defaults to the global `fetch`.
|
|
41
|
+
* Supplied by tests and by hosts that need a proxy or a retry policy.
|
|
42
|
+
*/
|
|
43
|
+
fetchImpl?: FetchImpl;
|
|
44
|
+
}
|
|
45
|
+
/** A vendor rejection, carrying what the provider actually said. */
|
|
46
|
+
declare class NotificationProviderError extends Error {
|
|
47
|
+
readonly status: number;
|
|
48
|
+
constructor(vendor: string, status: number, detail: string);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Absolutize an in-app link for a channel that leaves the app.
|
|
52
|
+
*
|
|
53
|
+
* A relative path is useless in an inbox and a fabricated localhost link is
|
|
54
|
+
* worse than none, so with no `appUrl` configured the link is simply dropped —
|
|
55
|
+
* The origin's rule, kept.
|
|
56
|
+
*/
|
|
57
|
+
declare function absoluteLink(link: string | undefined, appUrl: string | undefined): string | null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* WEB_PUSH transport — browser alerts over the Web Push protocol.
|
|
61
|
+
*
|
|
62
|
+
* What is the PACKAGE's, and stays here: the payload the service worker
|
|
63
|
+
* renders, the fan-out to every one of the user's browsers, the 404/410 PRUNE
|
|
64
|
+
* that makes `push_subscriptions` self-heal, and the rule that a send succeeds
|
|
65
|
+
* when at least one subscription accepted it and fails only when all errored.
|
|
66
|
+
*
|
|
67
|
+
* What is the VENDOR's, and crosses as a port: VAPID signing and RFC 8291
|
|
68
|
+
* payload encryption. Those need the `web-push` package, a node-only
|
|
69
|
+
* dependency this package must not force on a host that never enables the
|
|
70
|
+
* channel — so `@12-apps/notifications/web-push` exports the sender behind its
|
|
71
|
+
* own subpath and an optional peer, exactly as `./hono` does for the adapter.
|
|
72
|
+
*
|
|
73
|
+
* - `driver: 'vapid'` — `sender: vapidPushSender({ subject, publicKey,
|
|
74
|
+
* privateKey })` from that subpath (or any other signer).
|
|
75
|
+
* - `driver: 'log'` — dev driver: logs instead of sending.
|
|
76
|
+
* - no WEB_PUSH declaration — channel unavailable, router skips it.
|
|
77
|
+
*/
|
|
78
|
+
/** One browser subscription, as `PushManager.subscribe()` yields it. */
|
|
79
|
+
interface WebPushSubscription {
|
|
80
|
+
endpoint: string;
|
|
81
|
+
keys: {
|
|
82
|
+
p256dh: string;
|
|
83
|
+
auth: string;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The vendor seam: sign + encrypt + POST one payload to one push service.
|
|
88
|
+
*
|
|
89
|
+
* MUST reject with an error carrying a numeric `statusCode` when the push
|
|
90
|
+
* service answers one — that is how a gone subscription (404/410) is told
|
|
91
|
+
* apart from a transient failure, and therefore what makes the prune correct
|
|
92
|
+
* rather than destructive.
|
|
93
|
+
*/
|
|
94
|
+
type WebPushSender = (subscription: WebPushSubscription, payload: string) => Promise<void>;
|
|
95
|
+
/** The channel message the Web Push formatter produces (the SW's payload). */
|
|
96
|
+
interface WebPushMessage {
|
|
97
|
+
title: string;
|
|
98
|
+
body: string;
|
|
99
|
+
link: string | null;
|
|
100
|
+
data: Record<string, unknown>;
|
|
101
|
+
}
|
|
102
|
+
interface WebPushDriverDeclaration extends DriverDeclarationBase {
|
|
103
|
+
channel: 'WEB_PUSH';
|
|
104
|
+
/** Required by the `vapid` driver: the signer. */
|
|
105
|
+
sender?: WebPushSender;
|
|
106
|
+
/**
|
|
107
|
+
* The VAPID PUBLIC key, served to the browser by
|
|
108
|
+
* `GET <mount>/push-subscriptions` so the client can subscribe. Public by
|
|
109
|
+
* definition — the private key never crosses into this declaration.
|
|
110
|
+
*/
|
|
111
|
+
publicKey?: string;
|
|
112
|
+
logger?: NotificationLogger;
|
|
113
|
+
}
|
|
114
|
+
/** The subscriptions the transport reads and prunes (db-backed by the mount). */
|
|
115
|
+
interface WebPushSubscriptionSource {
|
|
116
|
+
list(userId: string): Promise<{
|
|
117
|
+
id: string;
|
|
118
|
+
endpoint: string;
|
|
119
|
+
p256dh: string;
|
|
120
|
+
auth: string;
|
|
121
|
+
}[]>;
|
|
122
|
+
prune(id: string): Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
declare const WEB_PUSH_DRIVERS: Record<string, (declaration: WebPushDriverDeclaration) => WebPushSender>;
|
|
125
|
+
declare function formatWebPush(content: NotificationContent): WebPushMessage;
|
|
126
|
+
declare function webPushTransport(declaration: WebPushDriverDeclaration, subscriptions: WebPushSubscriptionSource, extraDrivers?: Record<string, (d: WebPushDriverDeclaration) => WebPushSender>): NotificationTransport<WebPushMessage>;
|
|
127
|
+
|
|
128
|
+
export { type DriverDeclarationBase as D, type FetchImpl as F, NotificationProviderError as N, type WebPushSender as W, type WebPushSubscriptionSource as a, type WebPushDriverDeclaration as b, WEB_PUSH_DRIVERS as c, type WebPushMessage as d, type WebPushSubscription as e, absoluteLink as f, formatWebPush as g, webPushTransport as w };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { b as NotificationCategory, c as NotificationChannel } from './types-yq_o4N01.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every sentence this package can say to a USER, stated by the HOST.
|
|
5
|
+
*
|
|
6
|
+
* The copy lives in ONE table rather than in each screen so the api half and
|
|
7
|
+
* the react half can never disagree about a sentence — the 401 body the wire
|
|
8
|
+
* returns and the error the panel renders come from the same key.
|
|
9
|
+
*
|
|
10
|
+
* THE pt-BR TABLE THAT USED TO BE THE DEFAULT IS GONE. Its own docstring said
|
|
11
|
+
* what it was: "the product copy the surface shipped with", labelled in the
|
|
12
|
+
* source as one named application's "exact copy". A description of one adopter,
|
|
13
|
+
* shipped inside the package every other adopter installs, and reached by
|
|
14
|
+
* saying nothing.
|
|
15
|
+
*
|
|
16
|
+
* `categoryLabels` is the sharpest of the forty. The categories themselves
|
|
17
|
+
* became required config in the release before this one, precisely because
|
|
18
|
+
* WHICH categories exist is product vocabulary — and their LABELS kept
|
|
19
|
+
* defaulting, so a host that declared `['loans', 'fines']` got a labels map
|
|
20
|
+
* describing somebody else's four. Required categories with defaulted labels
|
|
21
|
+
* for a different host's categories is not a smaller version of the bug; it is
|
|
22
|
+
* the same bug with a compile-time gesture in front of it.
|
|
23
|
+
*
|
|
24
|
+
* So `messages` is REQUIRED and whole. The interface is the checklist, and the
|
|
25
|
+
* compiler names the sentences a host has not written yet.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* The sentences the SERVER half renders — and the whole of what a backend mount
|
|
29
|
+
* has to state.
|
|
30
|
+
*
|
|
31
|
+
* Split out when `messages` became required. Requiring the full forty on a
|
|
32
|
+
* server config would have made a backend-only adopter write three dozen
|
|
33
|
+
* sentences for screens it does not serve, which is the kind of tax that gets a
|
|
34
|
+
* required-config migration reverted rather than adopted. These four are the
|
|
35
|
+
* ones the router and the route descriptors actually put on a wire.
|
|
36
|
+
*/
|
|
37
|
+
interface NotificationWireMessages {
|
|
38
|
+
unauthenticated: string;
|
|
39
|
+
invalidBody: string;
|
|
40
|
+
operationFailed: string;
|
|
41
|
+
/** `POST /notifications/mark-read` with neither `ids` nor `all`. */
|
|
42
|
+
markReadTargetRequired: string;
|
|
43
|
+
}
|
|
44
|
+
/** Every sentence, wire and screen — what the REACT half needs. */
|
|
45
|
+
interface NotificationMessages extends NotificationWireMessages {
|
|
46
|
+
panelTitle: string;
|
|
47
|
+
markAllRead: string;
|
|
48
|
+
loading: string;
|
|
49
|
+
loadMore: string;
|
|
50
|
+
loadingMore: string;
|
|
51
|
+
loadFailedTitle: string;
|
|
52
|
+
loadFailedBody: string;
|
|
53
|
+
retry: string;
|
|
54
|
+
emptyTitle: string;
|
|
55
|
+
emptyBody: string;
|
|
56
|
+
openBell: string;
|
|
57
|
+
/** `(count) => 'Abrir notificações (3 não lidas)'`. */
|
|
58
|
+
openBellWithUnread: (count: number) => string;
|
|
59
|
+
unreadSuffix: string;
|
|
60
|
+
deleteOne: (title: string) => string;
|
|
61
|
+
justNow: string;
|
|
62
|
+
minutesAgo: (minutes: number) => string;
|
|
63
|
+
hoursAgo: (hours: number) => string;
|
|
64
|
+
daysAgo: (days: number) => string;
|
|
65
|
+
/** Locale for the fallback absolute date on rows older than a week. */
|
|
66
|
+
dateLocale: string;
|
|
67
|
+
preferencesTitle: string;
|
|
68
|
+
preferencesLead: string;
|
|
69
|
+
channelLabels: Record<string, string>;
|
|
70
|
+
channelUnavailableHints: Record<string, string>;
|
|
71
|
+
categoryLabels: Record<string, {
|
|
72
|
+
title: string;
|
|
73
|
+
description: string;
|
|
74
|
+
}>;
|
|
75
|
+
/** Fallback title for a category the host added but did not label. */
|
|
76
|
+
categoryFallbackTitle: (category: string) => string;
|
|
77
|
+
devicePushTitle: string;
|
|
78
|
+
devicePushIdle: string;
|
|
79
|
+
devicePushOn: string;
|
|
80
|
+
devicePushDenied: string;
|
|
81
|
+
devicePushFailed: string;
|
|
82
|
+
devicePushEnable: string;
|
|
83
|
+
devicePushEnabling: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The messages in force.
|
|
87
|
+
*
|
|
88
|
+
* A pass-through rather than a merge: there is nothing left to merge WITH, and
|
|
89
|
+
* that is the point of the change. The old version spread the host's table over
|
|
90
|
+
* the origin's, including PER KEY inside `channelLabels`,
|
|
91
|
+
* `channelUnavailableHints` and `categoryLabels` — so a host that relabelled one
|
|
92
|
+
* channel kept the origin's wording for the other three, and a host that
|
|
93
|
+
* labelled its own two categories kept the origin's four sitting beside them in
|
|
94
|
+
* the same screen.
|
|
95
|
+
*
|
|
96
|
+
* Kept as a function because all three mounts read it off a config object, and
|
|
97
|
+
* because a later rule (a blank-string refusal, say) belongs in one place.
|
|
98
|
+
*/
|
|
99
|
+
declare function messagesOf<T extends NotificationWireMessages>(config: {
|
|
100
|
+
messages: T;
|
|
101
|
+
}): T;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The preference POLICY, with no storage in it (12-15): which channels a
|
|
105
|
+
* category defaults to, how a stored JSON row is coerced onto the closed
|
|
106
|
+
* channel set, and how a partial save merges. `./server`'s store is the only
|
|
107
|
+
* thing that touches a database, so every rule here is unit-testable without
|
|
108
|
+
* one — and the react half can render the same defaults before the first read
|
|
109
|
+
* lands.
|
|
110
|
+
*
|
|
111
|
+
* Storage stores only EXPLICIT choices (one row per (user, category)); a
|
|
112
|
+
* missing row — or a missing channel key inside a row — falls back to
|
|
113
|
+
* {@link DEFAULT_CHANNEL_ROW}. Defaults: the free, low-friction channels
|
|
114
|
+
* (e-mail + web push) on; the paid per-message channels (SMS + WhatsApp) off
|
|
115
|
+
* until the user opts in. A host that disagrees passes `channelDefaults`.
|
|
116
|
+
*/
|
|
117
|
+
/** One category's channel toggles. */
|
|
118
|
+
type ChannelRow = Record<NotificationChannel, boolean>;
|
|
119
|
+
/** A user's full category × channel matrix. */
|
|
120
|
+
type ChannelMatrix = Record<NotificationCategory, ChannelRow>;
|
|
121
|
+
/** The policy applied when a user never touched a category's toggles. */
|
|
122
|
+
declare const DEFAULT_CHANNEL_ROW: ChannelRow;
|
|
123
|
+
/** The default matrix for one taxonomy (what the settings UI starts from). */
|
|
124
|
+
declare function defaultChannelMatrix(categories: readonly NotificationCategory[], channelDefaults?: Partial<ChannelRow>): ChannelMatrix;
|
|
125
|
+
/**
|
|
126
|
+
* Coerce a stored JSON `channels` map onto the closed channel set, filling the
|
|
127
|
+
* gaps from `base`. A stored row that predates a channel keeps that channel's
|
|
128
|
+
* default rather than reading as "off", which is what lets a new transport ship
|
|
129
|
+
* without a data migration.
|
|
130
|
+
*
|
|
131
|
+
* The consequence, and the rule it implies: a channel ADDED later turns itself
|
|
132
|
+
* ON for a user who had explicitly switched every channel in that category off,
|
|
133
|
+
* because their stored row has no key for it. That is harmless for the four
|
|
134
|
+
* shipped channels — the two that cost money default off — so **a new channel
|
|
135
|
+
* must be added with a `false` default** unless the user's existing consent
|
|
136
|
+
* already covers it. The alternative (reading a missing key as "off") would need
|
|
137
|
+
* a data migration for every existing row on every channel that ever ships.
|
|
138
|
+
*/
|
|
139
|
+
declare function mergeStoredRow(stored: unknown, base: ChannelRow): ChannelRow;
|
|
140
|
+
/** The channels enabled by one effective row — the router's gate. */
|
|
141
|
+
declare function enabledChannelsOf(row: ChannelRow): NotificationChannel[];
|
|
142
|
+
/**
|
|
143
|
+
* What a PUT writes for one category: the caller's toggles merged over the
|
|
144
|
+
* user's CURRENT effective row. A single-toggle save (how the settings UI
|
|
145
|
+
* writes) must never reset the category's other channels back to their
|
|
146
|
+
* defaults, which is exactly what a whole-row write would do.
|
|
147
|
+
*/
|
|
148
|
+
declare function mergeChoices(current: ChannelRow, choices: Partial<ChannelRow>): ChannelRow;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The inbox WIRE shape — the one contract the two halves share.
|
|
152
|
+
*
|
|
153
|
+
* It lives in the root entry rather than in `./server` or `./react` because
|
|
154
|
+
* both halves need it and neither owns it: the api serializes to it, the panel
|
|
155
|
+
* deserializes from it, and a change here is a change to both at once. That is
|
|
156
|
+
* the same reason the response envelope and the route paths are the package's
|
|
157
|
+
* and not the host's.
|
|
158
|
+
*/
|
|
159
|
+
/** One inbox entry as the notification centre renders it. */
|
|
160
|
+
interface InboxNotification {
|
|
161
|
+
id: string;
|
|
162
|
+
type: string;
|
|
163
|
+
category: string;
|
|
164
|
+
title: string;
|
|
165
|
+
body: string;
|
|
166
|
+
link: string | null;
|
|
167
|
+
data: Record<string, unknown>;
|
|
168
|
+
/** ISO-8601, or null while unread. */
|
|
169
|
+
readAt: string | null;
|
|
170
|
+
/** ISO-8601. */
|
|
171
|
+
createdAt: string;
|
|
172
|
+
}
|
|
173
|
+
/** One page of the owner's inbox. */
|
|
174
|
+
interface ListNotificationsResult {
|
|
175
|
+
items: InboxNotification[];
|
|
176
|
+
/** Cursor for the next page, or null when this page is the last. */
|
|
177
|
+
nextCursor: string | null;
|
|
178
|
+
}
|
|
179
|
+
/** A stored notification row, as the db seam hands it back. */
|
|
180
|
+
interface NotificationRow {
|
|
181
|
+
id: string;
|
|
182
|
+
userId: string;
|
|
183
|
+
clientId: string | null;
|
|
184
|
+
type: string;
|
|
185
|
+
category: string;
|
|
186
|
+
title: string;
|
|
187
|
+
body: string;
|
|
188
|
+
link: string | null;
|
|
189
|
+
data: unknown;
|
|
190
|
+
readAt: Date | null;
|
|
191
|
+
deletedAt: Date | null;
|
|
192
|
+
createdAt: Date;
|
|
193
|
+
}
|
|
194
|
+
/** Row → wire. Dates become ISO strings; a null `data` becomes `{}`. */
|
|
195
|
+
declare function inboxWire(row: NotificationRow): InboxNotification;
|
|
196
|
+
|
|
197
|
+
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, defaultChannelMatrix as d, enabledChannelsOf as e, mergeStoredRow as f, messagesOf as g, inboxWire as i, mergeChoices as m };
|