@12-apps/notifications 1.0.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/ADOPTING.md +316 -0
- package/README.md +153 -0
- package/package.json +94 -0
- package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +218 -0
- package/prisma/notifications.prisma +141 -0
- package/scripts/sync-notifications-schema.mjs +60 -0
- package/src/errors.ts +21 -0
- package/src/generators.ts +44 -0
- package/src/hono/index.ts +121 -0
- package/src/index.ts +73 -0
- package/src/messages.ts +156 -0
- package/src/phone.ts +57 -0
- package/src/preferences-core.ts +89 -0
- package/src/react/api.ts +111 -0
- package/src/react/bell-button.tsx +78 -0
- package/src/react/bell-icon.tsx +33 -0
- package/src/react/create-web-notifications.tsx +127 -0
- package/src/react/hooks.ts +74 -0
- package/src/react/inbox-state.ts +216 -0
- package/src/react/index.ts +61 -0
- package/src/react/panel.tsx +181 -0
- package/src/react/preferences-screen.tsx +242 -0
- package/src/react/relative-time.ts +18 -0
- package/src/react/row.tsx +98 -0
- package/src/react/transport.ts +72 -0
- package/src/react/web-push-client.ts +113 -0
- package/src/react/web-push-setup.tsx +167 -0
- package/src/server/by-permission.ts +255 -0
- package/src/server/context.ts +269 -0
- package/src/server/create-api-notifications.ts +215 -0
- package/src/server/db.ts +252 -0
- package/src/server/dispatch.ts +298 -0
- package/src/server/inbox.ts +155 -0
- package/src/server/index.ts +115 -0
- package/src/server/preferences.ts +103 -0
- package/src/server/push-subscriptions.ts +121 -0
- package/src/server/router.ts +275 -0
- package/src/server/routes.ts +218 -0
- package/src/server/transports/drivers.ts +148 -0
- package/src/server/transports/email.ts +141 -0
- package/src/server/transports/registry.ts +106 -0
- package/src/server/transports/sms.ts +120 -0
- package/src/server/transports/web-push.ts +168 -0
- package/src/server/transports/whatsapp.ts +183 -0
- package/src/types.ts +158 -0
- package/src/web-push/index.ts +70 -0
- package/src/wire.ts +62 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DRIVER port — the reason a host adds a vendor with a config entry and no
|
|
3
|
+
* code (12-15).
|
|
4
|
+
*
|
|
5
|
+
* future-pay's transports each read `process.env` directly:
|
|
6
|
+
* `NOTIFICATIONS_EMAIL_PROVIDER=resend` plus `RESEND_API_KEY`, and a second
|
|
7
|
+
* vendor meant editing the package. That is exactly backwards for a published
|
|
8
|
+
* package — it cannot know a host's variable names, and it must not be the
|
|
9
|
+
* thing that decides whether a channel is on. So a channel is configured by
|
|
10
|
+
* DECLARATION:
|
|
11
|
+
*
|
|
12
|
+
* transports: [
|
|
13
|
+
* { channel: 'EMAIL', driver: 'resend', apiKey, from },
|
|
14
|
+
* { channel: 'SMS', driver: 'log' },
|
|
15
|
+
* ]
|
|
16
|
+
*
|
|
17
|
+
* A channel with no declaration reports `supports() === false` and the router
|
|
18
|
+
* skips it: no delivery row, nothing fake-sent. A second vendor is one more
|
|
19
|
+
* entry in the built-in driver table (or `drivers` on the config, for a host's
|
|
20
|
+
* own), and the router, the registry and the other transports are untouched.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { normalizePhoneE164 } from '../../phone';
|
|
24
|
+
import type {
|
|
25
|
+
NotificationContent,
|
|
26
|
+
NotificationTransport,
|
|
27
|
+
TransportRecipient,
|
|
28
|
+
} from '../../types';
|
|
29
|
+
|
|
30
|
+
/** Every HTTP call a built-in driver makes goes through this, so a test can. */
|
|
31
|
+
export type FetchImpl = (
|
|
32
|
+
input: string,
|
|
33
|
+
init?: {
|
|
34
|
+
method?: string;
|
|
35
|
+
headers?: Record<string, string>;
|
|
36
|
+
body?: string;
|
|
37
|
+
},
|
|
38
|
+
) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
|
|
39
|
+
|
|
40
|
+
/** Shared by every declaration: which channel, which vendor. */
|
|
41
|
+
export interface DriverDeclarationBase {
|
|
42
|
+
/** Vendor key: `resend` / `twilio` / `meta` / `log`, or a host's own. */
|
|
43
|
+
driver: string;
|
|
44
|
+
/**
|
|
45
|
+
* The HTTP client the vendor call uses. Defaults to the global `fetch`.
|
|
46
|
+
* Supplied by tests and by hosts that need a proxy or a retry policy.
|
|
47
|
+
*/
|
|
48
|
+
fetchImpl?: FetchImpl;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A vendor rejection, carrying what the provider actually said. */
|
|
52
|
+
export class NotificationProviderError extends Error {
|
|
53
|
+
readonly status: number;
|
|
54
|
+
constructor(vendor: string, status: number, detail: string) {
|
|
55
|
+
super(`${vendor} rejected the message (${status} ${detail}).`);
|
|
56
|
+
this.name = 'NotificationProviderError';
|
|
57
|
+
this.status = status;
|
|
58
|
+
Object.setPrototypeOf(this, NotificationProviderError.prototype);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `fetch` and throw {@link NotificationProviderError} on a non-2xx. */
|
|
63
|
+
export async function postOrThrow(
|
|
64
|
+
vendor: string,
|
|
65
|
+
fetchImpl: FetchImpl | undefined,
|
|
66
|
+
url: string,
|
|
67
|
+
init: { headers: Record<string, string>; body: string },
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const call = fetchImpl ?? (globalThis.fetch as unknown as FetchImpl);
|
|
70
|
+
const response = await call(url, { method: 'POST', ...init });
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new NotificationProviderError(vendor, response.status, await response.text());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve one declaration against a driver table.
|
|
78
|
+
*
|
|
79
|
+
* Returning `null` — an unknown driver name — is a CONFIGURATION error, not a
|
|
80
|
+
* runtime one, so the caller (the transport factory) throws with the names it
|
|
81
|
+
* does know. A typo'd vendor that silently disabled a channel is the failure
|
|
82
|
+
* mode this whole seam exists to remove.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveDriver<TDeclaration extends DriverDeclarationBase, TDriver>(
|
|
85
|
+
channel: string,
|
|
86
|
+
declaration: TDeclaration,
|
|
87
|
+
table: Record<string, (declaration: TDeclaration) => TDriver>,
|
|
88
|
+
): TDriver {
|
|
89
|
+
const factory = table[declaration.driver];
|
|
90
|
+
if (!factory) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`@12-apps/notifications: unknown ${channel} driver "${declaration.driver}". ` +
|
|
93
|
+
`Known drivers: ${Object.keys(table).sort().join(', ')}.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return factory(declaration);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Absolutize an in-app link for a channel that leaves the app.
|
|
101
|
+
*
|
|
102
|
+
* A relative path is useless in an inbox and a fabricated localhost link is
|
|
103
|
+
* worse than none, so with no `appUrl` configured the link is simply dropped —
|
|
104
|
+
* future-pay's rule, kept.
|
|
105
|
+
*/
|
|
106
|
+
export function absoluteLink(link: string | undefined, appUrl: string | undefined): string | null {
|
|
107
|
+
if (!link || !appUrl) return null;
|
|
108
|
+
try {
|
|
109
|
+
return new URL(link, appUrl).toString();
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The two PHONE channels' shared skeleton.
|
|
117
|
+
*
|
|
118
|
+
* SMS and WhatsApp differ in their vendor, their message shape and their
|
|
119
|
+
* formatter — and in nothing else: both are unavailable for a recipient whose
|
|
120
|
+
* number will not normalize, both normalize before the vendor ever sees the
|
|
121
|
+
* destination, and both refuse rather than send when it cannot be. Stating that
|
|
122
|
+
* rule twice is how the two drift, which is exactly what happened to the
|
|
123
|
+
* transports' `supports()` gates before they funnelled through one helper.
|
|
124
|
+
*/
|
|
125
|
+
export function phoneChannel<TMessage>(
|
|
126
|
+
channel: 'SMS' | 'WHATSAPP',
|
|
127
|
+
options: {
|
|
128
|
+
/** Country calling code for a bare local number. Required — see `phone.ts`. */
|
|
129
|
+
defaultCountryCode: string;
|
|
130
|
+
format: (content: NotificationContent) => TMessage;
|
|
131
|
+
send: (toE164: string, message: TMessage) => Promise<void>;
|
|
132
|
+
},
|
|
133
|
+
): NotificationTransport<TMessage> {
|
|
134
|
+
const toE164 = (recipient: TransportRecipient): string | null =>
|
|
135
|
+
normalizePhoneE164(recipient.phone, {
|
|
136
|
+
defaultCountryCode: options.defaultCountryCode,
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
channel,
|
|
140
|
+
supports: (recipient) => toE164(recipient) !== null,
|
|
141
|
+
format: options.format,
|
|
142
|
+
async send(message, recipient) {
|
|
143
|
+
const to = toE164(recipient);
|
|
144
|
+
if (!to) throw new Error('Recipient has no usable phone number.');
|
|
145
|
+
await options.send(to, message);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NotificationContent,
|
|
3
|
+
NotificationLogger,
|
|
4
|
+
NotificationTransport,
|
|
5
|
+
TransportRecipient,
|
|
6
|
+
} from '../../types';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
absoluteLink,
|
|
10
|
+
postOrThrow,
|
|
11
|
+
resolveDriver,
|
|
12
|
+
type DriverDeclarationBase,
|
|
13
|
+
} from './drivers';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* EMAIL transport: formatter + sender behind the driver port.
|
|
17
|
+
*
|
|
18
|
+
* - `driver: 'resend'` — Resend's HTTP API (plain JSON POST, no SDK):
|
|
19
|
+
* `apiKey` + `from`.
|
|
20
|
+
* - `driver: 'log'` — dev/e2e driver: logs the message instead of sending
|
|
21
|
+
* (explicit opt-in, never a silent default).
|
|
22
|
+
* - no EMAIL declaration at all — `supports() === false`, router skips it.
|
|
23
|
+
*
|
|
24
|
+
* A different vendor (SES, an SMTP relay…) is one more entry in
|
|
25
|
+
* {@link EMAIL_DRIVERS} — this transport, the router and the registry stay
|
|
26
|
+
* untouched.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The channel message an email formatter produces. */
|
|
30
|
+
export interface EmailMessage {
|
|
31
|
+
subject: string;
|
|
32
|
+
text: string;
|
|
33
|
+
html: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The vendor seam: deliver one already-formatted email. Throws on failure. */
|
|
37
|
+
export interface EmailDriver {
|
|
38
|
+
send(to: string, message: EmailMessage): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EmailDriverDeclaration extends DriverDeclarationBase {
|
|
42
|
+
channel: 'EMAIL';
|
|
43
|
+
/** Resend: the API key. */
|
|
44
|
+
apiKey?: string;
|
|
45
|
+
/** Resend: the verified `From` address. */
|
|
46
|
+
from?: string;
|
|
47
|
+
/** Where the CTA link points; without it a link is dropped. */
|
|
48
|
+
appUrl?: string;
|
|
49
|
+
/** CTA label. pt-BR product copy by default. */
|
|
50
|
+
linkLabel?: string;
|
|
51
|
+
logger?: NotificationLogger;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function escapeHtml(value: string): string {
|
|
55
|
+
return value
|
|
56
|
+
.replaceAll('&', '&')
|
|
57
|
+
.replaceAll('<', '<')
|
|
58
|
+
.replaceAll('>', '>')
|
|
59
|
+
.replaceAll('"', '"');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const resendDriver = (declaration: EmailDriverDeclaration): EmailDriver => ({
|
|
63
|
+
async send(to, message) {
|
|
64
|
+
if (!declaration.apiKey || !declaration.from) {
|
|
65
|
+
throw new Error('The resend email driver needs both `apiKey` and `from`.');
|
|
66
|
+
}
|
|
67
|
+
await postOrThrow('Resend', declaration.fetchImpl, 'https://api.resend.com/emails', {
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
Authorization: `Bearer ${declaration.apiKey}`,
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
from: declaration.from,
|
|
74
|
+
to: [to],
|
|
75
|
+
subject: message.subject,
|
|
76
|
+
text: message.text,
|
|
77
|
+
html: message.html,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const logEmailDriver = (declaration: EmailDriverDeclaration): EmailDriver => ({
|
|
84
|
+
// Deliberately logs NO destination address — a recipient e-mail is PII and
|
|
85
|
+
// must never reach logs; the subject alone is enough for local debugging.
|
|
86
|
+
send(_to, message) {
|
|
87
|
+
declaration.logger?.info(
|
|
88
|
+
`[notifications:email] log driver suppressed a real send (subject="${message.subject}")`,
|
|
89
|
+
);
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/** The built-in email vendors. A host adds one by extending this table. */
|
|
95
|
+
export const EMAIL_DRIVERS: Record<
|
|
96
|
+
string,
|
|
97
|
+
(declaration: EmailDriverDeclaration) => EmailDriver
|
|
98
|
+
> = {
|
|
99
|
+
resend: resendDriver,
|
|
100
|
+
log: logEmailDriver,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Agnostic content → subject/text/html. The link becomes a trailing CTA, only
|
|
105
|
+
* when an app base URL is configured.
|
|
106
|
+
*/
|
|
107
|
+
export function formatEmail(
|
|
108
|
+
content: NotificationContent,
|
|
109
|
+
declaration: EmailDriverDeclaration,
|
|
110
|
+
): EmailMessage {
|
|
111
|
+
const href = absoluteLink(content.link, declaration.appUrl);
|
|
112
|
+
const label = declaration.linkLabel ?? 'Ver detalhes';
|
|
113
|
+
return {
|
|
114
|
+
subject: content.title,
|
|
115
|
+
text: href ? `${content.body}\n\n${href}` : content.body,
|
|
116
|
+
html: [
|
|
117
|
+
`<p><strong>${escapeHtml(content.title)}</strong></p>`,
|
|
118
|
+
`<p>${escapeHtml(content.body)}</p>`,
|
|
119
|
+
...(href ? [`<p><a href="${escapeHtml(href)}">${escapeHtml(label)}</a></p>`] : []),
|
|
120
|
+
].join('\n'),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function emailTransport(
|
|
125
|
+
declaration: EmailDriverDeclaration,
|
|
126
|
+
extraDrivers: Record<string, (d: EmailDriverDeclaration) => EmailDriver> = {},
|
|
127
|
+
): NotificationTransport<EmailMessage> {
|
|
128
|
+
const driver = resolveDriver('EMAIL', declaration, { ...EMAIL_DRIVERS, ...extraDrivers });
|
|
129
|
+
return {
|
|
130
|
+
channel: 'EMAIL',
|
|
131
|
+
// Truthiness, not `!== null`: an EMPTY STRING is a very ordinary DB value
|
|
132
|
+
// for a nullable column, and it used to pass this gate, earn a delivery row
|
|
133
|
+
// and then fail forever against `send`'s own `!recipient.email` check.
|
|
134
|
+
supports: (recipient: TransportRecipient) => Boolean(recipient.email),
|
|
135
|
+
format: (content) => formatEmail(content, declaration),
|
|
136
|
+
async send(message, recipient) {
|
|
137
|
+
if (!recipient.email) throw new Error('Recipient has no email address.');
|
|
138
|
+
await driver.send(recipient.email, message);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NotificationChannel,
|
|
3
|
+
NotificationLogger,
|
|
4
|
+
NotificationTransport,
|
|
5
|
+
} from '../../types';
|
|
6
|
+
|
|
7
|
+
import type { EmailDriver, EmailDriverDeclaration } from './email';
|
|
8
|
+
import { emailTransport } from './email';
|
|
9
|
+
import type { SmsDriver, SmsDriverDeclaration } from './sms';
|
|
10
|
+
import { smsTransport } from './sms';
|
|
11
|
+
import type { WebPushDriverDeclaration, WebPushSender, WebPushSubscriptionSource } from './web-push';
|
|
12
|
+
import { webPushTransport } from './web-push';
|
|
13
|
+
import type { WhatsAppDriver, WhatsAppDriverDeclaration } from './whatsapp';
|
|
14
|
+
import { whatsAppTransport } from './whatsapp';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The transport registry: the router dispatches through this, so adding a
|
|
18
|
+
* channel = registering one adapter and the router, generators and existing
|
|
19
|
+
* transports are untouched (open/closed).
|
|
20
|
+
*
|
|
21
|
+
* A mount declares its channels and gets a registry; nothing is process-wide.
|
|
22
|
+
* future-pay registered its four transports as an IMPORT SIDE EFFECT of the
|
|
23
|
+
* package's root entry, which made "which channels are on" a property of the
|
|
24
|
+
* module graph rather than of any configuration — importing the inbox helpers
|
|
25
|
+
* in a unit test silently armed four transports.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** One channel's declaration. The union is closed; the drivers are not. */
|
|
29
|
+
export type TransportDeclaration =
|
|
30
|
+
| EmailDriverDeclaration
|
|
31
|
+
| SmsDriverDeclaration
|
|
32
|
+
| WhatsAppDriverDeclaration
|
|
33
|
+
| WebPushDriverDeclaration;
|
|
34
|
+
|
|
35
|
+
/** A host's own vendors, added per channel without touching the package. */
|
|
36
|
+
export interface ExtraDrivers {
|
|
37
|
+
email?: Record<string, (declaration: EmailDriverDeclaration) => EmailDriver>;
|
|
38
|
+
sms?: Record<string, (declaration: SmsDriverDeclaration) => SmsDriver>;
|
|
39
|
+
whatsapp?: Record<string, (declaration: WhatsAppDriverDeclaration) => WhatsAppDriver>;
|
|
40
|
+
webPush?: Record<string, (declaration: WebPushDriverDeclaration) => WebPushSender>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TransportRegistry {
|
|
44
|
+
/** The adapter for `channel`, or null when the host declared none. */
|
|
45
|
+
get(channel: NotificationChannel): NotificationTransport<never> | null;
|
|
46
|
+
/** Every declared adapter, in declaration order. */
|
|
47
|
+
list(): NotificationTransport<never>[];
|
|
48
|
+
/** Register (or replace, last-wins) an adapter built by the host itself. */
|
|
49
|
+
register<TMessage>(transport: NotificationTransport<TMessage>): void;
|
|
50
|
+
/** The VAPID public key, when the WEB_PUSH channel declared one. */
|
|
51
|
+
webPushPublicKey(): string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function build(
|
|
55
|
+
declaration: TransportDeclaration,
|
|
56
|
+
subscriptions: WebPushSubscriptionSource,
|
|
57
|
+
extra: ExtraDrivers,
|
|
58
|
+
logger?: NotificationLogger,
|
|
59
|
+
): NotificationTransport<never> {
|
|
60
|
+
switch (declaration.channel) {
|
|
61
|
+
case 'EMAIL':
|
|
62
|
+
return emailTransport(declaration, extra.email ?? {}) as NotificationTransport<never>;
|
|
63
|
+
case 'SMS':
|
|
64
|
+
return smsTransport(declaration, extra.sms ?? {}) as NotificationTransport<never>;
|
|
65
|
+
case 'WHATSAPP':
|
|
66
|
+
return whatsAppTransport(
|
|
67
|
+
declaration,
|
|
68
|
+
extra.whatsapp ?? {},
|
|
69
|
+
logger,
|
|
70
|
+
) as NotificationTransport<never>;
|
|
71
|
+
case 'WEB_PUSH':
|
|
72
|
+
return webPushTransport(
|
|
73
|
+
declaration,
|
|
74
|
+
subscriptions,
|
|
75
|
+
extra.webPush ?? {},
|
|
76
|
+
) as NotificationTransport<never>;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createTransportRegistry(
|
|
81
|
+
declarations: readonly TransportDeclaration[],
|
|
82
|
+
subscriptions: WebPushSubscriptionSource,
|
|
83
|
+
extra: ExtraDrivers = {},
|
|
84
|
+
/** The mount's logger, for a declaration that is legal but probably wrong. */
|
|
85
|
+
logger?: NotificationLogger,
|
|
86
|
+
): TransportRegistry {
|
|
87
|
+
const transports = new Map<NotificationChannel, NotificationTransport<never>>();
|
|
88
|
+
let publicKey: string | null = null;
|
|
89
|
+
for (const declaration of declarations) {
|
|
90
|
+
if (transports.has(declaration.channel)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`@12-apps/notifications: the ${declaration.channel} channel is declared twice.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (declaration.channel === 'WEB_PUSH') publicKey = declaration.publicKey ?? null;
|
|
96
|
+
transports.set(declaration.channel, build(declaration, subscriptions, extra, logger));
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
get: (channel) => transports.get(channel) ?? null,
|
|
100
|
+
list: () => [...transports.values()],
|
|
101
|
+
register(transport) {
|
|
102
|
+
transports.set(transport.channel, transport as NotificationTransport<never>);
|
|
103
|
+
},
|
|
104
|
+
webPushPublicKey: () => publicKey,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NotificationContent,
|
|
3
|
+
NotificationLogger,
|
|
4
|
+
NotificationTransport,
|
|
5
|
+
} from '../../types';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
absoluteLink,
|
|
9
|
+
phoneChannel,
|
|
10
|
+
postOrThrow,
|
|
11
|
+
resolveDriver,
|
|
12
|
+
type DriverDeclarationBase,
|
|
13
|
+
} from './drivers';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SMS transport — Twilio behind the same driver port as email.
|
|
17
|
+
*
|
|
18
|
+
* - `driver: 'twilio'` — the Messages API (form-encoded POST with basic
|
|
19
|
+
* auth, no SDK): `accountSid`, `authToken`, `from` (an E.164 sender or a
|
|
20
|
+
* Messaging Service SID).
|
|
21
|
+
* - `driver: 'log'` — dev driver: logs instead of sending.
|
|
22
|
+
* - no SMS declaration — channel unavailable, router skips it.
|
|
23
|
+
*
|
|
24
|
+
* A recipient without a normalizable phone is unavailable on this channel
|
|
25
|
+
* regardless of the driver (see `../../phone.ts` for the verification caveat).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The channel message the SMS formatter produces: one plain text body. */
|
|
29
|
+
export interface SmsMessage {
|
|
30
|
+
body: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SmsDriver {
|
|
34
|
+
send(toE164: string, message: SmsMessage): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SmsDriverDeclaration extends DriverDeclarationBase {
|
|
38
|
+
channel: 'SMS';
|
|
39
|
+
accountSid?: string;
|
|
40
|
+
authToken?: string;
|
|
41
|
+
from?: string;
|
|
42
|
+
appUrl?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Country calling code for a bare local number, digits only (`'55'`, `'1'`).
|
|
45
|
+
* REQUIRED: this package assumes no country, because the one it used to
|
|
46
|
+
* assume turned a US number into a plausible Brazilian mobile and texted a
|
|
47
|
+
* stranger the customer's order (see `../../phone.ts`).
|
|
48
|
+
*/
|
|
49
|
+
defaultCountryCode: string;
|
|
50
|
+
logger?: NotificationLogger;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** SMS bodies are billed per 160-char segment — keep one message ≤ 3 segments. */
|
|
54
|
+
const MAX_SMS_CHARS = 480;
|
|
55
|
+
|
|
56
|
+
const twilioDriver = (declaration: SmsDriverDeclaration): SmsDriver => ({
|
|
57
|
+
async send(toE164, message) {
|
|
58
|
+
const sid = declaration.accountSid;
|
|
59
|
+
if (!sid || !declaration.authToken || !declaration.from) {
|
|
60
|
+
throw new Error('The twilio sms driver needs `accountSid`, `authToken` and `from`.');
|
|
61
|
+
}
|
|
62
|
+
const auth = Buffer.from(`${sid}:${declaration.authToken}`).toString('base64');
|
|
63
|
+
await postOrThrow(
|
|
64
|
+
'Twilio',
|
|
65
|
+
declaration.fetchImpl,
|
|
66
|
+
`https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(sid)}/Messages.json`,
|
|
67
|
+
{
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
70
|
+
Authorization: `Basic ${auth}`,
|
|
71
|
+
},
|
|
72
|
+
body: new URLSearchParams({
|
|
73
|
+
To: toE164,
|
|
74
|
+
From: declaration.from,
|
|
75
|
+
Body: message.body,
|
|
76
|
+
}).toString(),
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const logSmsDriver = (declaration: SmsDriverDeclaration): SmsDriver => ({
|
|
83
|
+
// Deliberately logs NO destination — a phone number is PII and must never
|
|
84
|
+
// reach logs; the message body alone is enough for local debugging.
|
|
85
|
+
send(_toE164, message) {
|
|
86
|
+
declaration.logger?.info(
|
|
87
|
+
`[notifications:sms] log driver suppressed a real send (body="${message.body}")`,
|
|
88
|
+
);
|
|
89
|
+
return Promise.resolve();
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const SMS_DRIVERS: Record<string, (declaration: SmsDriverDeclaration) => SmsDriver> = {
|
|
94
|
+
twilio: twilioDriver,
|
|
95
|
+
log: logSmsDriver,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Agnostic content → one plain SMS: "title: body (link)", length-capped. */
|
|
99
|
+
export function formatSms(
|
|
100
|
+
content: NotificationContent,
|
|
101
|
+
declaration: SmsDriverDeclaration,
|
|
102
|
+
): SmsMessage {
|
|
103
|
+
const parts = [`${content.title}: ${content.body}`];
|
|
104
|
+
const href = absoluteLink(content.link, declaration.appUrl);
|
|
105
|
+
if (href) parts.push(href);
|
|
106
|
+
const body = parts.join(' ');
|
|
107
|
+
return { body: body.length > MAX_SMS_CHARS ? `${body.slice(0, MAX_SMS_CHARS - 1)}…` : body };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function smsTransport(
|
|
111
|
+
declaration: SmsDriverDeclaration,
|
|
112
|
+
extraDrivers: Record<string, (d: SmsDriverDeclaration) => SmsDriver> = {},
|
|
113
|
+
): NotificationTransport<SmsMessage> {
|
|
114
|
+
const driver = resolveDriver('SMS', declaration, { ...SMS_DRIVERS, ...extraDrivers });
|
|
115
|
+
return phoneChannel<SmsMessage>('SMS', {
|
|
116
|
+
defaultCountryCode: declaration.defaultCountryCode,
|
|
117
|
+
format: (content) => formatSms(content, declaration),
|
|
118
|
+
send: (toE164, message) => driver.send(toE164, message),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NotificationContent,
|
|
3
|
+
NotificationLogger,
|
|
4
|
+
NotificationTransport,
|
|
5
|
+
TransportRecipient,
|
|
6
|
+
} from '../../types';
|
|
7
|
+
|
|
8
|
+
import { resolveDriver, type DriverDeclarationBase } from './drivers';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* WEB_PUSH transport — browser alerts over the Web Push protocol.
|
|
12
|
+
*
|
|
13
|
+
* What is the PACKAGE's, and stays here: the payload the service worker
|
|
14
|
+
* renders, the fan-out to every one of the user's browsers, the 404/410 PRUNE
|
|
15
|
+
* that makes `push_subscriptions` self-heal, and the rule that a send succeeds
|
|
16
|
+
* when at least one subscription accepted it and fails only when all errored.
|
|
17
|
+
*
|
|
18
|
+
* What is the VENDOR's, and crosses as a port: VAPID signing and RFC 8291
|
|
19
|
+
* payload encryption. Those need the `web-push` package, a node-only
|
|
20
|
+
* dependency this package must not force on a host that never enables the
|
|
21
|
+
* channel — so `@12-apps/notifications/web-push` exports the sender behind its
|
|
22
|
+
* own subpath and an optional peer, exactly as `./hono` does for the adapter.
|
|
23
|
+
*
|
|
24
|
+
* - `driver: 'vapid'` — `sender: vapidPushSender({ subject, publicKey,
|
|
25
|
+
* privateKey })` from that subpath (or any other signer).
|
|
26
|
+
* - `driver: 'log'` — dev driver: logs instead of sending.
|
|
27
|
+
* - no WEB_PUSH declaration — channel unavailable, router skips it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** One browser subscription, as `PushManager.subscribe()` yields it. */
|
|
31
|
+
export interface WebPushSubscription {
|
|
32
|
+
endpoint: string;
|
|
33
|
+
keys: { p256dh: string; auth: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The vendor seam: sign + encrypt + POST one payload to one push service.
|
|
38
|
+
*
|
|
39
|
+
* MUST reject with an error carrying a numeric `statusCode` when the push
|
|
40
|
+
* service answers one — that is how a gone subscription (404/410) is told
|
|
41
|
+
* apart from a transient failure, and therefore what makes the prune correct
|
|
42
|
+
* rather than destructive.
|
|
43
|
+
*/
|
|
44
|
+
export type WebPushSender = (
|
|
45
|
+
subscription: WebPushSubscription,
|
|
46
|
+
payload: string,
|
|
47
|
+
) => Promise<void>;
|
|
48
|
+
|
|
49
|
+
/** The channel message the Web Push formatter produces (the SW's payload). */
|
|
50
|
+
export interface WebPushMessage {
|
|
51
|
+
title: string;
|
|
52
|
+
body: string;
|
|
53
|
+
link: string | null;
|
|
54
|
+
data: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WebPushDriverDeclaration extends DriverDeclarationBase {
|
|
58
|
+
channel: 'WEB_PUSH';
|
|
59
|
+
/** Required by the `vapid` driver: the signer. */
|
|
60
|
+
sender?: WebPushSender;
|
|
61
|
+
/**
|
|
62
|
+
* The VAPID PUBLIC key, served to the browser by
|
|
63
|
+
* `GET <mount>/push-subscriptions` so the client can subscribe. Public by
|
|
64
|
+
* definition — the private key never crosses into this declaration.
|
|
65
|
+
*/
|
|
66
|
+
publicKey?: string;
|
|
67
|
+
logger?: NotificationLogger;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The subscriptions the transport reads and prunes (db-backed by the mount). */
|
|
71
|
+
export interface WebPushSubscriptionSource {
|
|
72
|
+
list(userId: string): Promise<{ id: string; endpoint: string; p256dh: string; auth: string }[]>;
|
|
73
|
+
prune(id: string): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const vapidDriver = (declaration: WebPushDriverDeclaration): WebPushSender => {
|
|
77
|
+
const sender = declaration.sender;
|
|
78
|
+
if (!sender) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
'The vapid web-push driver needs a `sender` — import `vapidPushSender` from ' +
|
|
81
|
+
'@12-apps/notifications/web-push.',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return sender;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const logWebPushDriver =
|
|
88
|
+
(declaration: WebPushDriverDeclaration): WebPushSender =>
|
|
89
|
+
(_subscription, payload) => {
|
|
90
|
+
// No endpoint logged: a push endpoint is a bearer capability for that
|
|
91
|
+
// browser, so it is a secret in exactly the way an e-mail address is PII.
|
|
92
|
+
declaration.logger?.info(
|
|
93
|
+
`[notifications:web-push] log driver suppressed a real send (${payload.length} bytes)`,
|
|
94
|
+
);
|
|
95
|
+
return Promise.resolve();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const WEB_PUSH_DRIVERS: Record<
|
|
99
|
+
string,
|
|
100
|
+
(declaration: WebPushDriverDeclaration) => WebPushSender
|
|
101
|
+
> = {
|
|
102
|
+
vapid: vapidDriver,
|
|
103
|
+
log: logWebPushDriver,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Status codes that mean "this subscription no longer exists — prune it". */
|
|
107
|
+
const GONE_STATUSES = new Set([404, 410]);
|
|
108
|
+
|
|
109
|
+
function statusCodeOf(error: unknown): number | null {
|
|
110
|
+
if (error && typeof error === 'object' && 'statusCode' in error) {
|
|
111
|
+
const code = (error as { statusCode: unknown }).statusCode;
|
|
112
|
+
return typeof code === 'number' ? code : null;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatWebPush(content: NotificationContent): WebPushMessage {
|
|
118
|
+
return {
|
|
119
|
+
title: content.title,
|
|
120
|
+
body: content.body,
|
|
121
|
+
link: content.link ?? null,
|
|
122
|
+
data: content.data ?? {},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function webPushTransport(
|
|
127
|
+
declaration: WebPushDriverDeclaration,
|
|
128
|
+
subscriptions: WebPushSubscriptionSource,
|
|
129
|
+
extraDrivers: Record<string, (d: WebPushDriverDeclaration) => WebPushSender> = {},
|
|
130
|
+
): NotificationTransport<WebPushMessage> {
|
|
131
|
+
const send = resolveDriver('WEB_PUSH', declaration, {
|
|
132
|
+
...WEB_PUSH_DRIVERS,
|
|
133
|
+
...extraDrivers,
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
channel: 'WEB_PUSH',
|
|
137
|
+
supports: (recipient: TransportRecipient) => recipient.pushSubscriptionCount > 0,
|
|
138
|
+
format: formatWebPush,
|
|
139
|
+
async send(message, recipient) {
|
|
140
|
+
const rows = await subscriptions.list(recipient.userId);
|
|
141
|
+
if (rows.length === 0) throw new Error('Recipient no longer has push subscriptions.');
|
|
142
|
+
const payload = JSON.stringify(message);
|
|
143
|
+
let delivered = 0;
|
|
144
|
+
let lastError: unknown = null;
|
|
145
|
+
for (const row of rows) {
|
|
146
|
+
try {
|
|
147
|
+
await send({ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } }, payload);
|
|
148
|
+
delivered += 1;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const status = statusCodeOf(error);
|
|
151
|
+
if (status !== null && GONE_STATUSES.has(status)) {
|
|
152
|
+
// Expired/unsubscribed browser — prune so future sends skip it.
|
|
153
|
+
await subscriptions.prune(row.id).catch(() => {
|
|
154
|
+
/* already pruned by a concurrent send */
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
lastError = error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (delivered === 0) {
|
|
162
|
+
throw lastError instanceof Error
|
|
163
|
+
? lastError
|
|
164
|
+
: new Error('No push subscription accepted the payload.');
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|