@devmonks/notify 0.0.0-stage → 0.4.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.
@@ -0,0 +1,87 @@
1
+ import { boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid, } from 'drizzle-orm/pg-core';
2
+ import { isNotNull, isNull } from 'drizzle-orm';
3
+ const ts = (name) => timestamp(name, { withTimezone: true, mode: 'date' });
4
+ /** A user id column of the same SQL type as the app's `users.id` (`uuid` or `text`), so the foreign keys line up. */
5
+ const userIdColumn = (usersId) => (name) => usersId.getSQLType() === 'uuid' ? uuid(name) : text(name);
6
+ /**
7
+ * Drizzle tables for the notify module. Pass the app's users table so the foreign keys point at it;
8
+ * the user id columns take its id's type (`uuid` or `text`). Re-export the result from the app's schema so its drizzle-kit
9
+ * generates the migration:
10
+ *
11
+ * export const { notifications, devices, notificationPrefs, notificationSettings, notificationDeliveries } =
12
+ * notifyTables({ users });
13
+ */
14
+ export function notifyTables({ users }) {
15
+ const userId = userIdColumn(users.id);
16
+ /** In-app inbox. One row per inApp delivery. */
17
+ const notifications = pgTable('notification', {
18
+ id: text('id').primaryKey(),
19
+ userId: userId('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
20
+ type: text('type').notNull(),
21
+ title: text('title').notNull(),
22
+ body: text('body').notNull().default(''),
23
+ url: text('url'),
24
+ data: jsonb('data').notNull().default({}),
25
+ /** What it is about (an alert, a message), so "who got X" is a plain indexed lookup. */
26
+ sourceId: text('source_id'),
27
+ /** Who caused it, when a user did. Cleared when that user is deleted. */
28
+ actorId: userId('actor_id').references(() => users.id, { onDelete: 'set null' }),
29
+ readAt: ts('read_at'),
30
+ createdAt: ts('created_at').notNull().defaultNow(),
31
+ }, (t) => [
32
+ /** Keyset paging: newest first, id breaks ties. */
33
+ index('notification_user_created_idx').on(t.userId, t.createdAt.desc(), t.id.desc()),
34
+ index('notification_user_unread_idx').on(t.userId).where(isNull(t.readAt)),
35
+ /** One row per user for each thing it is about; also the "who got X" lookup. */
36
+ uniqueIndex('notification_source_uq').on(t.type, t.sourceId, t.userId).where(isNotNull(t.sourceId)),
37
+ index('notification_created_idx').on(t.createdAt),
38
+ ]);
39
+ /** One row per app install. `id` is generated by the app on first launch and stays stable; the push token may rotate. */
40
+ const devices = pgTable('notify_device', {
41
+ id: text('id').primaryKey(),
42
+ userId: userId('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
43
+ /** 'ios' | 'android' */
44
+ platform: text('platform').notNull(),
45
+ token: text('token').notNull(),
46
+ appVersion: text('app_version').notNull().default(''),
47
+ createdAt: ts('created_at').notNull().defaultNow(),
48
+ /** Bumped on every registration. Devices are pruned by it only when the app enables pruneInactiveAfterDays. */
49
+ lastSeenAt: ts('last_seen_at').notNull().defaultNow(),
50
+ /** Consecutive permanent failures; the device is deleted at 3. A success resets it. */
51
+ failureCount: integer('failure_count').notNull().default(0),
52
+ }, (t) => [uniqueIndex('notify_device_token_uq').on(t.token), index('notify_device_user_idx').on(t.userId)]);
53
+ /** Per category and channel switch. A missing row means the app's default. */
54
+ const notificationPrefs = pgTable('notification_pref', {
55
+ userId: userId('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
56
+ category: text('category').notNull(),
57
+ channel: text('channel').notNull(),
58
+ enabled: boolean('enabled').notNull(),
59
+ }, (t) => [primaryKey({ columns: [t.userId, t.category, t.channel] })]);
60
+ /** Quiet hours. A missing row means none. Times are local 'HH:MM'. */
61
+ const notificationSettings = pgTable('notification_settings', {
62
+ userId: userId('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
63
+ quietFrom: text('quiet_from'),
64
+ quietTo: text('quiet_to'),
65
+ updatedAt: ts('updated_at').notNull().defaultNow(),
66
+ });
67
+ /** Audit and dedupe of every planned delivery. The unique idempotency key makes retries and double triggers harmless. */
68
+ const notificationDeliveries = pgTable('notification_delivery', {
69
+ id: text('id').primaryKey(),
70
+ idempotencyKey: text('idempotency_key').notNull(),
71
+ type: text('type').notNull(),
72
+ /** No FK: also used for address-only sends (sign-in codes before the account exists), as "email:<address>". */
73
+ userId: text('user_id').notNull(),
74
+ channel: text('channel').notNull(),
75
+ deviceId: text('device_id'),
76
+ /** pending | sent | failed | skipped */
77
+ status: text('status').notNull().default('pending'),
78
+ error: text('error'),
79
+ attempts: integer('attempts').notNull().default(0),
80
+ createdAt: ts('created_at').notNull().defaultNow(),
81
+ }, (t) => [
82
+ uniqueIndex('notification_delivery_key_uq').on(t.idempotencyKey),
83
+ index('notification_delivery_user_idx').on(t.userId, t.createdAt),
84
+ index('notification_delivery_created_idx').on(t.createdAt),
85
+ ]);
86
+ return { notifications, devices, notificationPrefs, notificationSettings, notificationDeliveries };
87
+ }
@@ -0,0 +1,18 @@
1
+ import type { Channel, Logger } from '../types.js';
2
+ import type { EmailBrand } from './types.js';
3
+ export interface EmailOptions {
4
+ brand?: EmailBrand;
5
+ /** Prefix for relative `content.url` values so the email button works, e.g. "https://example.com". */
6
+ linkBase?: string;
7
+ }
8
+ export interface ResendOptions extends EmailOptions {
9
+ apiKey: string;
10
+ /** e.g. `Acme <hello@example.com>` */
11
+ from: string;
12
+ /** Injected for tests; defaults to global fetch. */
13
+ fetch?: typeof fetch;
14
+ }
15
+ /** Email through the Resend HTTP API (fetch only, Workers-safe). 429 and 5xx are retryable. */
16
+ export declare function resendEmail({ apiKey, from, fetch: f, brand, linkBase }: ResendOptions): Channel;
17
+ /** Dev: prints the rendered plain-text email instead of sending. */
18
+ export declare function logEmail(logger: Logger, opts?: EmailOptions): Channel;
@@ -0,0 +1,51 @@
1
+ import { errorMessage, retryableStatus, tryJson } from '../util.js';
2
+ import { renderContentEmail } from './render.js';
3
+ /** Email through the Resend HTTP API (fetch only, Workers-safe). 429 and 5xx are retryable. */
4
+ export function resendEmail({ apiKey, from, fetch: f = fetch, brand, linkBase }) {
5
+ return {
6
+ name: 'email',
7
+ async send(to, content, ctx) {
8
+ let rendered;
9
+ try {
10
+ rendered = await renderContentEmail(content, brand, linkBase);
11
+ }
12
+ catch (e) {
13
+ return { ok: false, retryable: false, error: `render failed: ${errorMessage(e)}` };
14
+ }
15
+ let res;
16
+ try {
17
+ res = await f('https://api.resend.com/emails', {
18
+ method: 'POST',
19
+ headers: {
20
+ Authorization: `Bearer ${apiKey}`,
21
+ 'Content-Type': 'application/json',
22
+ ...(ctx?.idempotencyKey ? { 'Idempotency-Key': ctx.idempotencyKey } : {}),
23
+ },
24
+ body: JSON.stringify({ from, to: [to], subject: rendered.subject, html: rendered.html, text: rendered.text }),
25
+ });
26
+ }
27
+ catch (e) {
28
+ return { ok: false, retryable: true, error: `network: ${errorMessage(e)}` };
29
+ }
30
+ if (res.ok) {
31
+ const json = (await res.json().catch(() => ({})));
32
+ return json.id ? { ok: true, providerId: json.id } : { ok: true };
33
+ }
34
+ const raw = await res.text().catch(() => '');
35
+ const j = tryJson(raw);
36
+ const reason = [j?.name, j?.message].filter(Boolean).join(': ') || raw.slice(0, 300);
37
+ return { ok: false, retryable: retryableStatus(res.status), error: `resend ${res.status}: ${reason}` };
38
+ },
39
+ };
40
+ }
41
+ /** Dev: prints the rendered plain-text email instead of sending. */
42
+ export function logEmail(logger, opts = {}) {
43
+ return {
44
+ name: 'email',
45
+ async send(to, content) {
46
+ const r = await renderContentEmail(content, opts.brand, opts.linkBase);
47
+ logger.info(`[email] to=${to} subject=${JSON.stringify(r.subject)}\n${r.text}`);
48
+ return { ok: true };
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,5 @@
1
+ export { resendEmail, logEmail, type ResendOptions, type EmailOptions } from './adapter.js';
2
+ export { BaseLayout, DEFAULT_BRAND, type BaseLayoutProps } from './layout.js';
3
+ export { simpleEmail, type SimpleEmailProps } from './simple.js';
4
+ export { renderEmail, renderContentEmail } from './render.js';
5
+ export type { EmailBrand, EmailPalette, EmailTemplate } from './types.js';
@@ -0,0 +1,4 @@
1
+ export { resendEmail, logEmail } from './adapter.js';
2
+ export { BaseLayout, DEFAULT_BRAND } from './layout.js';
3
+ export { simpleEmail } from './simple.js';
4
+ export { renderEmail, renderContentEmail } from './render.js';
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { EmailBrand, EmailPalette } from './types.js';
3
+ export declare const DEFAULT_BRAND: EmailBrand;
4
+ export declare const DEFAULT_PALETTE: EmailPalette;
5
+ export declare const FONT = "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif";
6
+ export declare const paletteOf: (brand: EmailBrand) => EmailPalette;
7
+ export interface BaseLayoutProps {
8
+ brand?: EmailBrand;
9
+ /** Inbox preview line (shown next to the subject, hidden in the body). */
10
+ preview?: string;
11
+ children: ReactNode;
12
+ }
13
+ /** The one layout every email uses: logo or name, a card with the content, a footer. */
14
+ export declare function BaseLayout({ brand, preview, children }: BaseLayoutProps): import("react").JSX.Element;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Body, Container, Head, Html, Img, Link, Preview, Section, Text } from '@react-email/components';
3
+ export const DEFAULT_BRAND = { name: 'Notifications', accent: '#221A2B' };
4
+ export const DEFAULT_PALETTE = {
5
+ background: '#f6f4f9',
6
+ surface: '#ffffff',
7
+ line: '#ece6f2',
8
+ rule: 'transparent',
9
+ heading: '#221a2b',
10
+ text: '#4a4152',
11
+ muted: '#6f6578',
12
+ codeFill: '#f6f4f9',
13
+ codeBorder: '#ece6f2',
14
+ };
15
+ export const FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
16
+ export const paletteOf = (brand) => ({ ...DEFAULT_PALETTE, ...brand.palette });
17
+ /** The one layout every email uses: logo or name, a card with the content, a footer. */
18
+ export function BaseLayout({ brand = DEFAULT_BRAND, preview, children }) {
19
+ const c = paletteOf(brand);
20
+ const accent = brand.accent ?? DEFAULT_BRAND.accent;
21
+ const body = { backgroundColor: c.background, margin: 0, padding: '40px 16px', fontFamily: FONT };
22
+ const card = {
23
+ backgroundColor: c.surface,
24
+ border: `1px solid ${c.line}`,
25
+ borderRadius: '18px',
26
+ padding: '36px 32px 28px',
27
+ color: c.heading,
28
+ };
29
+ const footer = { color: c.muted, fontSize: '12px', lineHeight: '18px', margin: '4px 0 0', textAlign: 'center' };
30
+ const signoff = {
31
+ fontFamily: brand.headingFont ?? FONT,
32
+ fontStyle: 'italic',
33
+ fontSize: '18px',
34
+ lineHeight: '24px',
35
+ color: accent,
36
+ margin: '24px 0 0',
37
+ };
38
+ return (_jsxs(Html, { lang: "en", children: [_jsxs(Head, { children: [_jsx("meta", { name: "color-scheme", content: "light" }), _jsx("meta", { name: "supported-color-schemes", content: "light" }), brand.fontCssUrl ? _jsx("link", { rel: "stylesheet", href: brand.fontCssUrl }) : null] }), preview ? _jsx(Preview, { children: preview }) : null, _jsx(Body, { style: body, children: _jsxs(Container, { style: { maxWidth: '560px', margin: '0 auto' }, children: [_jsx(Section, { style: { textAlign: 'center', margin: '0 0 24px' }, children: brand.logoUrl ? (_jsx(Img, { src: brand.logoUrl, alt: brand.name, width: brand.logoWidth, height: brand.logoHeight ?? 32, style: { display: 'inline-block', border: 0, outline: 'none' } })) : (_jsx(Text, { style: { margin: 0, fontSize: '18px', fontWeight: 700, color: accent }, children: brand.name })) }), _jsxs(Section, { style: card, children: [children, brand.signoff ? _jsx(Text, { style: signoff, children: brand.signoff }) : null] }), _jsxs(Section, { style: { padding: '24px 0 0' }, children: [brand.footer ? _jsx(Text, { style: { ...footer, color: c.text, fontSize: '13px' }, children: brand.footer }) : null, brand.links?.length ? (_jsx(Text, { style: footer, children: brand.links.map((l, i) => (_jsxs("span", { children: [i > 0 ? ' · ' : '', _jsx(Link, { href: l.url, style: { color: c.muted, textDecoration: 'underline' }, children: l.label })] }, l.url))) })) : null, brand.supportEmail ? (_jsxs(Text, { style: footer, children: ["Need help? ", _jsx(Link, { href: `mailto:${brand.supportEmail}`, style: { color: accent }, children: brand.supportEmail })] })) : null] })] }) })] }));
39
+ }
@@ -0,0 +1,17 @@
1
+ import type { ReactElement } from 'react';
2
+ import type { Content } from '../types.js';
3
+ import type { EmailBrand } from './types.js';
4
+ /** HTML and plain-text bodies of one email, rendered from the same React element. */
5
+ export declare function renderEmail(element: ReactElement): Promise<{
6
+ html: string;
7
+ text: string;
8
+ }>;
9
+ /**
10
+ * Subject + html + text for a Content. No custom template: simpleEmail from title, body and url.
11
+ * [linkBase] prefixes a relative `content.url`; a url that is still relative gets no button.
12
+ */
13
+ export declare function renderContentEmail(content: Content, brand?: EmailBrand, linkBase?: string): Promise<{
14
+ subject: string;
15
+ html: string;
16
+ text: string;
17
+ }>;
@@ -0,0 +1,38 @@
1
+ import { render } from '@react-email/render';
2
+ import { DEFAULT_BRAND } from './layout.js';
3
+ import { simpleEmail } from './simple.js';
4
+ /** Headings keep their case in the plain-text version (the library upper-cases them by default). */
5
+ const HEADING_SELECTORS = ['h1', 'h2', 'h3'].map((selector) => ({ selector, options: { uppercase: false } }));
6
+ /** HTML and plain-text bodies of one email, rendered from the same React element. */
7
+ export async function renderEmail(element) {
8
+ const [html, text] = await Promise.all([
9
+ render(element),
10
+ render(element, { plainText: true, htmlToTextOptions: { selectors: HEADING_SELECTORS } }),
11
+ ]);
12
+ return { html, text };
13
+ }
14
+ const isAbsolute = (url) => /^https?:\/\//.test(url);
15
+ /**
16
+ * Subject + html + text for a Content. No custom template: simpleEmail from title, body and url.
17
+ * [linkBase] prefixes a relative `content.url`; a url that is still relative gets no button.
18
+ */
19
+ export async function renderContentEmail(content, brand = DEFAULT_BRAND, linkBase) {
20
+ const subject = content.email?.subject ?? content.title;
21
+ const template = content.email?.template;
22
+ let element;
23
+ if (template) {
24
+ element = template({ brand, ...content.email?.props });
25
+ }
26
+ else {
27
+ let url = content.url;
28
+ if (url && linkBase && !isAbsolute(url))
29
+ url = `${linkBase.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`;
30
+ element = simpleEmail({
31
+ brand,
32
+ heading: content.title,
33
+ body: content.body,
34
+ ...(url && isAbsolute(url) ? { button: { label: `Open ${brand.name}`, url } } : {}),
35
+ });
36
+ }
37
+ return { subject, ...(await renderEmail(element)) };
38
+ }
@@ -0,0 +1,17 @@
1
+ import type { EmailBrand } from './types.js';
2
+ export interface SimpleEmailProps {
3
+ heading: string;
4
+ /** Plain text. Blank lines start a new paragraph. */
5
+ body: string;
6
+ /** A short code shown large (sign-in codes). */
7
+ code?: string;
8
+ button?: {
9
+ label: string;
10
+ url: string;
11
+ };
12
+ /** Inbox preview line. Defaults to the first line of the body. */
13
+ preview?: string;
14
+ brand?: EmailBrand;
15
+ }
16
+ /** Heading, paragraphs, optional code box, optional button: covers text-and-button emails without a custom template. */
17
+ export declare function simpleEmail({ heading, body, code, button, preview, brand }: SimpleEmailProps): import("react").JSX.Element;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, Heading, Hr, Section, Text } from '@react-email/components';
3
+ import { BaseLayout, DEFAULT_BRAND, FONT, paletteOf } from './layout.js';
4
+ /** Heading, paragraphs, optional code box, optional button: covers text-and-button emails without a custom template. */
5
+ export function simpleEmail({ heading, body, code, button, preview, brand = DEFAULT_BRAND }) {
6
+ const accent = brand.accent ?? DEFAULT_BRAND.accent;
7
+ const c = paletteOf(brand);
8
+ const paragraphs = body.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
9
+ const h = {
10
+ fontFamily: brand.headingFont ?? FONT,
11
+ fontSize: brand.headingFont ? '32px' : '22px',
12
+ fontWeight: brand.headingFont ? 400 : 700,
13
+ lineHeight: brand.headingFont ? '38px' : '28px',
14
+ margin: '0 0 16px',
15
+ color: c.heading,
16
+ };
17
+ const p = { fontSize: '16px', lineHeight: '26px', margin: '0 0 16px', color: c.text };
18
+ const codeBox = {
19
+ fontFamily: '"SF Mono", Menlo, Consolas, "Courier New", monospace',
20
+ fontSize: '34px',
21
+ letterSpacing: '0.35em',
22
+ fontWeight: 700,
23
+ textAlign: 'center',
24
+ color: c.heading,
25
+ backgroundColor: c.codeFill,
26
+ border: `1px dashed ${c.codeBorder}`,
27
+ borderRadius: '14px',
28
+ padding: '18px 16px 18px calc(16px + 0.35em)',
29
+ margin: '8px 0 20px',
30
+ };
31
+ const btn = {
32
+ backgroundColor: accent,
33
+ color: '#ffffff',
34
+ borderRadius: '999px',
35
+ padding: '14px 28px',
36
+ fontSize: '15px',
37
+ fontWeight: 600,
38
+ textDecoration: 'none',
39
+ display: 'inline-block',
40
+ };
41
+ return (_jsxs(BaseLayout, { brand: brand, preview: preview ?? paragraphs[0] ?? heading, children: [_jsx(Heading, { as: "h1", style: h, children: heading }), c.rule !== 'transparent' ? _jsx(Hr, { style: { border: 'none', borderTop: `3px solid ${c.rule}`, width: '44px', margin: '-4px 0 20px 0' } }) : null, paragraphs.map((text, i) => (_jsx(Text, { style: p, children: text }, i))), code ? _jsx(Text, { style: codeBox, children: code }) : null, button ? (_jsx(Section, { style: { margin: '8px 0 0' }, children: _jsx(Button, { href: button.url, style: btn, children: button.label }) })) : null] }));
42
+ }
@@ -0,0 +1,46 @@
1
+ import type { ReactElement } from 'react';
2
+ /** A React Email template: props in, element out. The adapter also passes `brand` (see EmailBrand). */
3
+ export type EmailTemplate<P = any> = (props: P) => ReactElement;
4
+ /** Brand slot for emails (BaseLayout, simpleEmail). */
5
+ export interface EmailBrand {
6
+ name: string;
7
+ /** CSS colour for buttons and accents. */
8
+ accent?: string;
9
+ /** Absolute URL of a logo image; the name is shown as text without it. */
10
+ logoUrl?: string;
11
+ /** Footer line, e.g. a tagline or address. */
12
+ footer?: string;
13
+ supportEmail?: string;
14
+ /** Logo size in CSS px (keep the image's aspect ratio; serve it at 2-3x). */
15
+ logoWidth?: number;
16
+ logoHeight?: number;
17
+ /** Colours; anything unset falls back to the neutral default. */
18
+ palette?: Partial<EmailPalette>;
19
+ /** Heading font stack (body text always uses the system stack). */
20
+ headingFont?: string;
21
+ /** Web font stylesheet for clients that load one (Apple Mail, iOS); others use the stack's fallbacks. */
22
+ fontCssUrl?: string;
23
+ /** Small links under the card, e.g. website, privacy. */
24
+ links?: {
25
+ label: string;
26
+ url: string;
27
+ }[];
28
+ /** Line under the content, e.g. "See you at 8 pm". */
29
+ signoff?: string;
30
+ }
31
+ export interface EmailPalette {
32
+ /** Outside the card. */
33
+ background: string;
34
+ /** The card. */
35
+ surface: string;
36
+ /** Card border. */
37
+ line: string;
38
+ /** Short rule under the heading; transparent = none. */
39
+ rule: string;
40
+ heading: string;
41
+ text: string;
42
+ muted: string;
43
+ /** Code box fill and border. */
44
+ codeFill: string;
45
+ codeBorder: string;
46
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/hash.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function hashParts(parts: (string | undefined)[]): string;
package/dist/hash.js ADDED
@@ -0,0 +1,17 @@
1
+ /** Small synchronous 3x53-bit string hash (cyrb53), hex. Not cryptographic: used for stable dedupe keys. */
2
+ function cyrb53(str, seed) {
3
+ let h1 = 0xdeadbeef ^ seed;
4
+ let h2 = 0x41c6ce57 ^ seed;
5
+ for (let i = 0; i < str.length; i++) {
6
+ const ch = str.charCodeAt(i);
7
+ h1 = Math.imul(h1 ^ ch, 2654435761);
8
+ h2 = Math.imul(h2 ^ ch, 1597334677);
9
+ }
10
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
11
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
12
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
13
+ }
14
+ export function hashParts(parts) {
15
+ const s = parts.map((p) => p ?? '').join('\u001f');
16
+ return [cyrb53(s, 1), cyrb53(s, 2), cyrb53(s, 3)].map((n) => n.toString(16).padStart(14, '0')).join('');
17
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export { createNotifier, defineNotification, type Notifier, type NotifierConfig } from './notifier.js';
3
+ export { inQuietHours } from './quiet-hours.js';
4
+ export { fcmPush, logPush, type FcmOptions } from './adapters/push.js';
5
+ export { inApp, logSms } from './adapters/sms-inapp.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export { createNotifier, defineNotification } from './notifier.js';
3
+ export { inQuietHours } from './quiet-hours.js';
4
+ export { fcmPush, logPush } from './adapters/push.js';
5
+ export { inApp, logSms } from './adapters/sms-inapp.js';
@@ -0,0 +1,14 @@
1
+ import type { Channel, Delivery, NotificationType, PlanOptions, Recipient, SendResult } from './types.js';
2
+ export interface NotifierConfig {
3
+ channels: Channel[];
4
+ types: NotificationType[];
5
+ }
6
+ export interface Notifier {
7
+ /** Pure: decides who gets what on which channel. Sends nothing. */
8
+ plan<D>(type: string | NotificationType<D>, recipient: Recipient, data: D, opts?: PlanOptions): Delivery[];
9
+ /** Sends ONE delivery through its channel adapter. Never throws. */
10
+ send(delivery: Delivery): Promise<SendResult>;
11
+ }
12
+ /** Identity: gives `render` its data type. */
13
+ export declare function defineNotification<D>(type: NotificationType<D>): NotificationType<D>;
14
+ export declare function createNotifier(config: NotifierConfig): Notifier;
@@ -0,0 +1,77 @@
1
+ import { hashParts } from './hash.js';
2
+ import { inQuietHours } from './quiet-hours.js';
3
+ import { errorMessage } from './util.js';
4
+ /** Channels muted during quiet hours. In-app and email are not interruptive. */
5
+ const QUIET_MUTED = new Set(['push', 'sms']);
6
+ /** Identity: gives `render` its data type. */
7
+ export function defineNotification(type) {
8
+ return type;
9
+ }
10
+ function addressesFor(channel, r) {
11
+ switch (channel) {
12
+ case 'email':
13
+ return r.email ? [{ to: r.email }] : [];
14
+ case 'sms':
15
+ return r.phone ? [{ to: r.phone }] : [];
16
+ case 'push': {
17
+ const seen = new Set();
18
+ return r.devices.filter((d) => d.token && !seen.has(d.token) && seen.add(d.token)).map((d) => ({ to: d.token, deviceId: d.id }));
19
+ }
20
+ case 'inApp':
21
+ return r.inAppEnabled === false ? [] : [{ to: r.id }];
22
+ default:
23
+ return [{ to: r.id }];
24
+ }
25
+ }
26
+ export function createNotifier(config) {
27
+ const channels = Object.fromEntries(config.channels.map((c) => [c.name, c]));
28
+ const types = Object.fromEntries(config.types.map((t) => [t.type, t]));
29
+ return {
30
+ plan(type, recipient, data, opts = {}) {
31
+ const def = typeof type === 'string' ? (Object.hasOwn(types, type) ? types[type] : undefined) : type;
32
+ if (!def)
33
+ throw new Error(`Unknown notification type: ${String(type)}`);
34
+ const now = opts.now ?? new Date();
35
+ const quiet = !def.transactional && inQuietHours(now, recipient.timeZone, recipient.quietHours);
36
+ const categoryPrefs = Object.hasOwn(recipient.prefs, def.category) ? (recipient.prefs[def.category] ?? {}) : {};
37
+ let content;
38
+ const out = [];
39
+ for (const channel of def.channels) {
40
+ if (!channels[channel])
41
+ continue;
42
+ if (!def.transactional && categoryPrefs[channel] === false)
43
+ continue;
44
+ if (quiet && QUIET_MUTED.has(channel))
45
+ continue;
46
+ const addresses = addressesFor(channel, recipient);
47
+ if (addresses.length === 0)
48
+ continue;
49
+ content ??= def.render(data, { locale: recipient.locale });
50
+ for (const { to, deviceId } of addresses) {
51
+ out.push({
52
+ id: crypto.randomUUID(),
53
+ type: def.type,
54
+ channel,
55
+ to,
56
+ content,
57
+ recipientId: recipient.id,
58
+ ...(deviceId ? { deviceId } : {}),
59
+ idempotencyKey: hashParts([def.type, recipient.id, channel, to, opts.dedupeKey]),
60
+ });
61
+ }
62
+ }
63
+ return out;
64
+ },
65
+ async send(delivery) {
66
+ const channel = channels[delivery.channel];
67
+ if (!channel)
68
+ return { ok: false, retryable: false, error: `No adapter for channel "${delivery.channel}"` };
69
+ try {
70
+ return await channel.send(delivery.to, delivery.content, { type: delivery.type, idempotencyKey: delivery.idempotencyKey });
71
+ }
72
+ catch (e) {
73
+ return { ok: false, retryable: true, error: errorMessage(e) };
74
+ }
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,5 @@
1
+ /** True when the recipient's local time is inside [from, to). Crosses midnight when from > to; from == to is "never". */
2
+ export declare function inQuietHours(now: Date, tz: string, q: {
3
+ from: string;
4
+ to: string;
5
+ } | undefined): boolean;
@@ -0,0 +1,45 @@
1
+ const cache = new Map();
2
+ function fmt(tz) {
3
+ let f = cache.get(tz);
4
+ if (!f) {
5
+ try {
6
+ f = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hourCycle: 'h23', hour: '2-digit', minute: '2-digit' });
7
+ }
8
+ catch {
9
+ f = fmt('UTC');
10
+ }
11
+ cache.set(tz, f);
12
+ }
13
+ return f;
14
+ }
15
+ /** Minutes since local midnight for [now] in [tz] (bad zones fall back to UTC). */
16
+ function localMinutes(now, tz) {
17
+ let h = 0;
18
+ let m = 0;
19
+ for (const p of fmt(tz).formatToParts(now)) {
20
+ if (p.type === 'hour')
21
+ h = Number(p.value) % 24;
22
+ if (p.type === 'minute')
23
+ m = Number(p.value);
24
+ }
25
+ return h * 60 + m;
26
+ }
27
+ function parseHHMM(s) {
28
+ const m = /^(\d{1,2}):(\d{2})$/.exec(s);
29
+ if (!m)
30
+ return null;
31
+ const h = Number(m[1]);
32
+ const min = Number(m[2]);
33
+ return h < 24 && min < 60 ? h * 60 + min : null;
34
+ }
35
+ /** True when the recipient's local time is inside [from, to). Crosses midnight when from > to; from == to is "never". */
36
+ export function inQuietHours(now, tz, q) {
37
+ if (!q)
38
+ return false;
39
+ const from = parseHHMM(q.from);
40
+ const to = parseHHMM(q.to);
41
+ if (from === null || to === null || from === to)
42
+ return false;
43
+ const t = localMinutes(now, tz);
44
+ return from < to ? t >= from && t < to : t >= from || t < to;
45
+ }