@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,183 @@
|
|
|
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
|
+
* WHATSAPP transport — Meta's WhatsApp Cloud API behind the driver port.
|
|
17
|
+
*
|
|
18
|
+
* - `driver: 'meta'` — the Cloud API (JSON POST with a bearer token, no
|
|
19
|
+
* SDK): `accessToken` + `phoneNumberId`.
|
|
20
|
+
* - `driver: 'log'` — dev driver: logs instead of sending.
|
|
21
|
+
* - no WHATSAPP declaration — channel unavailable, router skips it.
|
|
22
|
+
*
|
|
23
|
+
* Template/session-window rule: WhatsApp only accepts FREE-FORM text inside a
|
|
24
|
+
* 24h customer-service window; business-initiated messages outside it require
|
|
25
|
+
* a pre-approved TEMPLATE. With `templateName` set the transport sends that
|
|
26
|
+
* template with two body parameters — {{1}} = title, {{2}} = body (language
|
|
27
|
+
* `templateLanguage`, default `pt_BR`). Without it the transport sends
|
|
28
|
+
* free-form text, and a send outside the session window FAILS with the
|
|
29
|
+
* provider's error recorded on the delivery row — the documented fallback
|
|
30
|
+
* behaviour, visible instead of silent.
|
|
31
|
+
*
|
|
32
|
+
* The window cannot be TRACKED from here (only Meta knows when the customer
|
|
33
|
+
* last wrote), so a host that declares WHATSAPP with no `templateName` and then
|
|
34
|
+
* emits business-initiated notifications has every send rejected. That is
|
|
35
|
+
* visible on the delivery rows, but only once they exist — so the mount warns
|
|
36
|
+
* about the combination the moment the declaration is read, which is the one
|
|
37
|
+
* moment a misconfiguration is cheap to notice.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** The channel message the WhatsApp formatter produces. */
|
|
41
|
+
export interface WhatsAppMessage {
|
|
42
|
+
/** Free-form text used inside the session window / without a template. */
|
|
43
|
+
text: string;
|
|
44
|
+
/** Template body parameters ({{1}} title, {{2}} body) when templated. */
|
|
45
|
+
templateParameters: [title: string, body: string];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WhatsAppDriver {
|
|
49
|
+
send(toE164: string, message: WhatsAppMessage): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface WhatsAppDriverDeclaration extends DriverDeclarationBase {
|
|
53
|
+
channel: 'WHATSAPP';
|
|
54
|
+
accessToken?: string;
|
|
55
|
+
phoneNumberId?: string;
|
|
56
|
+
templateName?: string;
|
|
57
|
+
/** Default `pt_BR`. */
|
|
58
|
+
templateLanguage?: string;
|
|
59
|
+
/** Graph API base, so a host can pin a version. */
|
|
60
|
+
graphApiBase?: string;
|
|
61
|
+
appUrl?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Country calling code for a bare local number, digits only (`'55'`, `'1'`).
|
|
64
|
+
* REQUIRED for the same reason SMS requires it — see `../../phone.ts`.
|
|
65
|
+
*/
|
|
66
|
+
defaultCountryCode: string;
|
|
67
|
+
logger?: NotificationLogger;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_GRAPH_API_BASE = 'https://graph.facebook.com/v20.0';
|
|
71
|
+
|
|
72
|
+
function templatePayload(
|
|
73
|
+
toDigits: string,
|
|
74
|
+
message: WhatsAppMessage,
|
|
75
|
+
declaration: WhatsAppDriverDeclaration,
|
|
76
|
+
): object {
|
|
77
|
+
return {
|
|
78
|
+
messaging_product: 'whatsapp',
|
|
79
|
+
to: toDigits,
|
|
80
|
+
type: 'template',
|
|
81
|
+
template: {
|
|
82
|
+
name: declaration.templateName,
|
|
83
|
+
language: { code: declaration.templateLanguage ?? 'pt_BR' },
|
|
84
|
+
components: [
|
|
85
|
+
{
|
|
86
|
+
type: 'body',
|
|
87
|
+
parameters: message.templateParameters.map((text) => ({ type: 'text', text })),
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function textPayload(toDigits: string, message: WhatsAppMessage): object {
|
|
95
|
+
return {
|
|
96
|
+
messaging_product: 'whatsapp',
|
|
97
|
+
to: toDigits,
|
|
98
|
+
type: 'text',
|
|
99
|
+
text: { body: message.text },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const metaDriver = (declaration: WhatsAppDriverDeclaration): WhatsAppDriver => ({
|
|
104
|
+
async send(toE164, message) {
|
|
105
|
+
if (!declaration.accessToken || !declaration.phoneNumberId) {
|
|
106
|
+
throw new Error('The meta whatsapp driver needs `accessToken` and `phoneNumberId`.');
|
|
107
|
+
}
|
|
108
|
+
// The Cloud API addresses recipients by bare digits (no `+`).
|
|
109
|
+
const toDigits = toE164.replace('+', '');
|
|
110
|
+
const payload = declaration.templateName
|
|
111
|
+
? templatePayload(toDigits, message, declaration)
|
|
112
|
+
: textPayload(toDigits, message);
|
|
113
|
+
const base = declaration.graphApiBase ?? DEFAULT_GRAPH_API_BASE;
|
|
114
|
+
await postOrThrow(
|
|
115
|
+
'WhatsApp Cloud API',
|
|
116
|
+
declaration.fetchImpl,
|
|
117
|
+
`${base}/${encodeURIComponent(declaration.phoneNumberId)}/messages`,
|
|
118
|
+
{
|
|
119
|
+
headers: {
|
|
120
|
+
'Content-Type': 'application/json',
|
|
121
|
+
Authorization: `Bearer ${declaration.accessToken}`,
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify(payload),
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const logWhatsAppDriver = (declaration: WhatsAppDriverDeclaration): WhatsAppDriver => ({
|
|
130
|
+
// Deliberately logs NO destination — a phone number is PII and must never
|
|
131
|
+
// reach logs; the message text alone is enough for local debugging.
|
|
132
|
+
send(_toE164, message) {
|
|
133
|
+
declaration.logger?.info(
|
|
134
|
+
`[notifications:whatsapp] log driver suppressed a real send (text="${message.text}")`,
|
|
135
|
+
);
|
|
136
|
+
return Promise.resolve();
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export const WHATSAPP_DRIVERS: Record<
|
|
141
|
+
string,
|
|
142
|
+
(declaration: WhatsAppDriverDeclaration) => WhatsAppDriver
|
|
143
|
+
> = {
|
|
144
|
+
meta: metaDriver,
|
|
145
|
+
log: logWhatsAppDriver,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Agnostic content → WhatsApp text + template parameters. */
|
|
149
|
+
export function formatWhatsApp(
|
|
150
|
+
content: NotificationContent,
|
|
151
|
+
declaration: WhatsAppDriverDeclaration,
|
|
152
|
+
): WhatsAppMessage {
|
|
153
|
+
const lines = [`*${content.title}*`, '', content.body];
|
|
154
|
+
const href = absoluteLink(content.link, declaration.appUrl);
|
|
155
|
+
if (href) lines.push('', href);
|
|
156
|
+
return { text: lines.join('\n'), templateParameters: [content.title, content.body] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function whatsAppTransport(
|
|
160
|
+
declaration: WhatsAppDriverDeclaration,
|
|
161
|
+
extraDrivers: Record<string, (d: WhatsAppDriverDeclaration) => WhatsAppDriver> = {},
|
|
162
|
+
logger?: NotificationLogger,
|
|
163
|
+
): NotificationTransport<WhatsAppMessage> {
|
|
164
|
+
const driver = resolveDriver('WHATSAPP', declaration, {
|
|
165
|
+
...WHATSAPP_DRIVERS,
|
|
166
|
+
...extraDrivers,
|
|
167
|
+
});
|
|
168
|
+
if (!declaration.templateName) {
|
|
169
|
+
// Not a throw: free-form IS correct for a host that only replies inside the
|
|
170
|
+
// 24h window. It is a warning because the other reading — business-initiated
|
|
171
|
+
// alerts with no template — is a channel that never delivers anything.
|
|
172
|
+
logger?.error(
|
|
173
|
+
'[notifications] WHATSAPP is declared with no `templateName`: only free-form ' +
|
|
174
|
+
'replies inside the 24h customer-service window will be accepted, and every ' +
|
|
175
|
+
'business-initiated send will be rejected by the Graph API.',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return phoneChannel<WhatsAppMessage>('WHATSAPP', {
|
|
179
|
+
defaultCountryCode: declaration.defaultCountryCode,
|
|
180
|
+
format: (content) => formatWhatsApp(content, declaration),
|
|
181
|
+
send: (toE164, message) => driver.send(toE164, message),
|
|
182
|
+
});
|
|
183
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
|
|
16
|
+
/** Transport channels a notification can fan out to (DB CHECK mirrors this). */
|
|
17
|
+
export const NOTIFICATION_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'WEB_PUSH'] as const;
|
|
18
|
+
export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The DEFAULT preference categories — the granularity at which a user chooses
|
|
22
|
+
* channels. Every notification `type` belongs to exactly one category via its
|
|
23
|
+
* generator.
|
|
24
|
+
*
|
|
25
|
+
* A host may replace the set entirely (`categories` on the server config): it
|
|
26
|
+
* is product vocabulary, not machinery. These four are the future-pay set, and
|
|
27
|
+
* the packaged migration deliberately puts **no CHECK** on
|
|
28
|
+
* `notifications.category` — a closed set in the schema would be wrong for
|
|
29
|
+
* every host but the first. Unlike `channel` and `status`, which ARE the
|
|
30
|
+
* library's own closed sets and do carry one.
|
|
31
|
+
*/
|
|
32
|
+
export const NOTIFICATION_CATEGORIES = ['orders', 'payments', 'stock', 'system'] as const;
|
|
33
|
+
export type NotificationCategory = string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Per-channel delivery lifecycle (DB CHECK mirrors this).
|
|
37
|
+
*
|
|
38
|
+
* `SENDING` is the CLAIM: exactly one dispatcher moves a row out of `QUEUED`,
|
|
39
|
+
* so two dispatchers can never both send the same delivery. A row left
|
|
40
|
+
* `SENDING` is a dispatcher that died mid-send, and the sweep reclaims it once
|
|
41
|
+
* it is older than the cutoff.
|
|
42
|
+
*
|
|
43
|
+
* `DEAD` is terminal: the attempt ceiling was reached (or the recipient no
|
|
44
|
+
* longer exists), and no sweep will pick the row up again. Without it a
|
|
45
|
+
* permanently invalid destination is a billed provider call on every sweep,
|
|
46
|
+
* forever, and the sweep's working set only grows.
|
|
47
|
+
*/
|
|
48
|
+
export type DeliveryStatus = 'QUEUED' | 'SENDING' | 'SENT' | 'FAILED' | 'DEAD';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Channel-agnostic content a generator produces. This is what the inbox stores
|
|
52
|
+
* verbatim and what every transport's formatter receives — no channel may leak
|
|
53
|
+
* its wire format into it.
|
|
54
|
+
*/
|
|
55
|
+
export interface NotificationContent {
|
|
56
|
+
title: string;
|
|
57
|
+
body: string;
|
|
58
|
+
/** In-app deep link (a same-origin path such as `/orders/123`). */
|
|
59
|
+
link?: string;
|
|
60
|
+
/** Structured extras for consumers that want more than text. */
|
|
61
|
+
data?: Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Who receives a notification. `clientId` scopes it to a tenant when set. */
|
|
65
|
+
export interface NotificationRecipient {
|
|
66
|
+
userId: string;
|
|
67
|
+
clientId?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A typed domain event handed to `notify`. `type` selects the registered
|
|
72
|
+
* generator; `payload` is that generator's typed input. Callers never touch
|
|
73
|
+
* channels, formatting, or preferences.
|
|
74
|
+
*/
|
|
75
|
+
export interface NotificationEvent<TPayload = unknown> {
|
|
76
|
+
type: string;
|
|
77
|
+
recipient: NotificationRecipient;
|
|
78
|
+
payload: TPayload;
|
|
79
|
+
}
|
|
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
|
+
export 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
|
+
/**
|
|
96
|
+
* The recipient as a transport sees them: resolved destinations only. Built by
|
|
97
|
+
* the router from the host's contact directory + the push subscriptions this
|
|
98
|
+
* package owns; transports use it to answer
|
|
99
|
+
* {@link NotificationTransport.supports}.
|
|
100
|
+
*/
|
|
101
|
+
export interface TransportRecipient {
|
|
102
|
+
userId: string;
|
|
103
|
+
email: string | null;
|
|
104
|
+
/** Phone as the host stores it (transports normalize per provider rules). */
|
|
105
|
+
phone: string | null;
|
|
106
|
+
/** How many active browser push subscriptions the user holds. */
|
|
107
|
+
pushSubscriptionCount: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One pluggable channel adapter: a FORMATTER (agnostic content → channel
|
|
112
|
+
* message) plus a SENDER. Adding a channel = registering one of these; the
|
|
113
|
+
* router dispatches through the registry and needs no change.
|
|
114
|
+
*
|
|
115
|
+
* `send` resolves on success and THROWS on failure — the router records the
|
|
116
|
+
* error on the delivery row and isolates it from other channels. Sends must be
|
|
117
|
+
* retry-safe: the router may re-dispatch a QUEUED/FAILED delivery.
|
|
118
|
+
*/
|
|
119
|
+
export interface NotificationTransport<TMessage = unknown> {
|
|
120
|
+
channel: NotificationChannel;
|
|
121
|
+
/**
|
|
122
|
+
* Whether this recipient is addressable on this channel right now — the
|
|
123
|
+
* destination exists (e-mail / phone / push subscription) AND the provider
|
|
124
|
+
* is configured. `false` simply skips the channel (no delivery row).
|
|
125
|
+
*/
|
|
126
|
+
supports(recipient: TransportRecipient): boolean;
|
|
127
|
+
/** Transform the agnostic content into this channel's message shape. */
|
|
128
|
+
format(content: NotificationContent): TMessage;
|
|
129
|
+
/** Deliver the formatted message to the recipient. Throws on failure. */
|
|
130
|
+
send(message: TMessage, recipient: TransportRecipient): Promise<void>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The host's product vocabulary. Everything below the surface (routing,
|
|
135
|
+
* delivery rows, retries, the wire) is identical for every host; WHICH
|
|
136
|
+
* categories exist and how they are labelled is not.
|
|
137
|
+
*/
|
|
138
|
+
export interface NotificationTaxonomy {
|
|
139
|
+
/** The preference categories, in the order the settings screen lists them. */
|
|
140
|
+
categories: readonly NotificationCategory[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The taxonomy in force, defaulted to the four future-pay categories. */
|
|
144
|
+
export function taxonomyOf(config: {
|
|
145
|
+
categories?: readonly NotificationCategory[];
|
|
146
|
+
}): NotificationTaxonomy {
|
|
147
|
+
const categories = config.categories ?? NOTIFICATION_CATEGORIES;
|
|
148
|
+
if (categories.length === 0) {
|
|
149
|
+
throw new Error('@12-apps/notifications: `categories` must not be empty.');
|
|
150
|
+
}
|
|
151
|
+
return { categories: [...categories] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The host's logger. Defaults to the console (the @12-apps/jobs precedent). */
|
|
155
|
+
export interface NotificationLogger {
|
|
156
|
+
info(message: string, ...meta: unknown[]): void;
|
|
157
|
+
error(message: string, ...meta: unknown[]): void;
|
|
158
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import webpush from 'web-push';
|
|
2
|
+
|
|
3
|
+
import type { WebPushSender, WebPushSubscription } from '../server/transports/web-push';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `@12-apps/notifications/web-push` — the VAPID sender, behind its own subpath.
|
|
7
|
+
*
|
|
8
|
+
* VAPID signing and RFC 8291 payload encryption need the `web-push` package: a
|
|
9
|
+
* node-only dependency with its own crypto surface, which a host that never
|
|
10
|
+
* turns the channel on must not be made to install. So it is an OPTIONAL peer
|
|
11
|
+
* reached only through this subpath — the same arrangement `./hono` has, and the
|
|
12
|
+
* same reason `@12-apps/payments-backend` keeps its adapters off its root entry.
|
|
13
|
+
* Neither the root entry nor `./server` imports this file, so a bundle that
|
|
14
|
+
* never mentions web push never resolves `web-push`.
|
|
15
|
+
*
|
|
16
|
+
* The host's whole wiring:
|
|
17
|
+
*
|
|
18
|
+
* import { vapidPushSender } from '@12-apps/notifications/web-push';
|
|
19
|
+
*
|
|
20
|
+
* transports: [
|
|
21
|
+
* {
|
|
22
|
+
* channel: 'WEB_PUSH',
|
|
23
|
+
* driver: 'vapid',
|
|
24
|
+
* publicKey: env.VAPID_PUBLIC_KEY,
|
|
25
|
+
* sender: vapidPushSender({
|
|
26
|
+
* subject: env.VAPID_SUBJECT,
|
|
27
|
+
* publicKey: env.VAPID_PUBLIC_KEY,
|
|
28
|
+
* privateKey: env.VAPID_PRIVATE_KEY,
|
|
29
|
+
* }),
|
|
30
|
+
* },
|
|
31
|
+
* ]
|
|
32
|
+
*
|
|
33
|
+
* Generate the key pair once with `npx web-push generate-vapid-keys`; `subject`
|
|
34
|
+
* is the `mailto:` or https contact URL the push services require.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export interface VapidPushSenderConfig {
|
|
38
|
+
/** `mailto:ops@example.com` or an https contact URL. */
|
|
39
|
+
subject: string;
|
|
40
|
+
publicKey: string;
|
|
41
|
+
privateKey: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A signer for the WEB_PUSH transport.
|
|
46
|
+
*
|
|
47
|
+
* Errors pass through UNWRAPPED, and that is load-bearing: `web-push` rejects
|
|
48
|
+
* with an error carrying `statusCode`, and the transport reads it to tell a GONE
|
|
49
|
+
* subscription (404/410, prune it) from a transient failure (keep it, record the
|
|
50
|
+
* error, retry on the next sweep). Wrapping the error would turn the prune into
|
|
51
|
+
* a no-op and let dead subscriptions accumulate forever.
|
|
52
|
+
*/
|
|
53
|
+
export function vapidPushSender(config: VapidPushSenderConfig): WebPushSender {
|
|
54
|
+
if (!config.subject || !config.publicKey || !config.privateKey) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'vapidPushSender() needs `subject`, `publicKey` and `privateKey` — a partially ' +
|
|
57
|
+
'configured signer would fail on the first real send instead of at boot.',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return async (subscription: WebPushSubscription, payload: string): Promise<void> => {
|
|
61
|
+
// Set per send rather than once at module load: a process may hold two
|
|
62
|
+
// mounts (a platform sender and a tenant sender), and `web-push` keeps the
|
|
63
|
+
// details in module state.
|
|
64
|
+
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
|
|
65
|
+
await webpush.sendNotification(
|
|
66
|
+
{ endpoint: subscription.endpoint, keys: subscription.keys },
|
|
67
|
+
payload,
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbox WIRE shape — the one contract the two halves share.
|
|
3
|
+
*
|
|
4
|
+
* It lives in the root entry rather than in `./server` or `./react` because
|
|
5
|
+
* both halves need it and neither owns it: the api serializes to it, the panel
|
|
6
|
+
* deserializes from it, and a change here is a change to both at once. That is
|
|
7
|
+
* the same reason the response envelope and the route paths are the package's
|
|
8
|
+
* and not the host's.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One inbox entry as the notification centre renders it. */
|
|
12
|
+
export interface InboxNotification {
|
|
13
|
+
id: string;
|
|
14
|
+
type: string;
|
|
15
|
+
category: string;
|
|
16
|
+
title: string;
|
|
17
|
+
body: string;
|
|
18
|
+
link: string | null;
|
|
19
|
+
data: Record<string, unknown>;
|
|
20
|
+
/** ISO-8601, or null while unread. */
|
|
21
|
+
readAt: string | null;
|
|
22
|
+
/** ISO-8601. */
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One page of the owner's inbox. */
|
|
27
|
+
export interface ListNotificationsResult {
|
|
28
|
+
items: InboxNotification[];
|
|
29
|
+
/** Cursor for the next page, or null when this page is the last. */
|
|
30
|
+
nextCursor: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A stored notification row, as the db seam hands it back. */
|
|
34
|
+
export interface NotificationRow {
|
|
35
|
+
id: string;
|
|
36
|
+
userId: string;
|
|
37
|
+
clientId: string | null;
|
|
38
|
+
type: string;
|
|
39
|
+
category: string;
|
|
40
|
+
title: string;
|
|
41
|
+
body: string;
|
|
42
|
+
link: string | null;
|
|
43
|
+
data: unknown;
|
|
44
|
+
readAt: Date | null;
|
|
45
|
+
deletedAt: Date | null;
|
|
46
|
+
createdAt: Date;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Row → wire. Dates become ISO strings; a null `data` becomes `{}`. */
|
|
50
|
+
export function inboxWire(row: NotificationRow): InboxNotification {
|
|
51
|
+
return {
|
|
52
|
+
id: row.id,
|
|
53
|
+
type: row.type,
|
|
54
|
+
category: row.category,
|
|
55
|
+
title: row.title,
|
|
56
|
+
body: row.body,
|
|
57
|
+
link: row.link,
|
|
58
|
+
data: (row.data ?? {}) as Record<string, unknown>,
|
|
59
|
+
readAt: row.readAt ? row.readAt.toISOString() : null,
|
|
60
|
+
createdAt: row.createdAt.toISOString(),
|
|
61
|
+
};
|
|
62
|
+
}
|