@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.
Files changed (47) hide show
  1. package/ADOPTING.md +316 -0
  2. package/README.md +153 -0
  3. package/package.json +94 -0
  4. package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +218 -0
  5. package/prisma/notifications.prisma +141 -0
  6. package/scripts/sync-notifications-schema.mjs +60 -0
  7. package/src/errors.ts +21 -0
  8. package/src/generators.ts +44 -0
  9. package/src/hono/index.ts +121 -0
  10. package/src/index.ts +73 -0
  11. package/src/messages.ts +156 -0
  12. package/src/phone.ts +57 -0
  13. package/src/preferences-core.ts +89 -0
  14. package/src/react/api.ts +111 -0
  15. package/src/react/bell-button.tsx +78 -0
  16. package/src/react/bell-icon.tsx +33 -0
  17. package/src/react/create-web-notifications.tsx +127 -0
  18. package/src/react/hooks.ts +74 -0
  19. package/src/react/inbox-state.ts +216 -0
  20. package/src/react/index.ts +61 -0
  21. package/src/react/panel.tsx +181 -0
  22. package/src/react/preferences-screen.tsx +242 -0
  23. package/src/react/relative-time.ts +18 -0
  24. package/src/react/row.tsx +98 -0
  25. package/src/react/transport.ts +72 -0
  26. package/src/react/web-push-client.ts +113 -0
  27. package/src/react/web-push-setup.tsx +167 -0
  28. package/src/server/by-permission.ts +255 -0
  29. package/src/server/context.ts +269 -0
  30. package/src/server/create-api-notifications.ts +215 -0
  31. package/src/server/db.ts +252 -0
  32. package/src/server/dispatch.ts +298 -0
  33. package/src/server/inbox.ts +155 -0
  34. package/src/server/index.ts +115 -0
  35. package/src/server/preferences.ts +103 -0
  36. package/src/server/push-subscriptions.ts +121 -0
  37. package/src/server/router.ts +275 -0
  38. package/src/server/routes.ts +218 -0
  39. package/src/server/transports/drivers.ts +148 -0
  40. package/src/server/transports/email.ts +141 -0
  41. package/src/server/transports/registry.ts +106 -0
  42. package/src/server/transports/sms.ts +120 -0
  43. package/src/server/transports/web-push.ts +168 -0
  44. package/src/server/transports/whatsapp.ts +183 -0
  45. package/src/types.ts +158 -0
  46. package/src/web-push/index.ts +70 -0
  47. package/src/wire.ts +62 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The notification-preferences screen: the category × channel matrix over
3
+ * `GET/PUT <mount>/notification-preferences`.
4
+ *
5
+ * Toggles auto-save (optimistic, per change); channels that cannot reach the
6
+ * user right now (no phone on file / channel not declared) render disabled with
7
+ * a hint. Web Push additionally carries the per-BROWSER enable step, since a
8
+ * preference alone cannot reach a device that never subscribed.
9
+ */
10
+ import { useCallback, useEffect, useState, type JSX } from 'react';
11
+
12
+ import { LoadingState } from '@12-apps/ui/data-display/LoadingState';
13
+ import { Switch } from '@12-apps/ui/form/Switch';
14
+ import { Box } from '@12-apps/ui/mui/Box';
15
+ import { Text } from '@12-apps/ui/typography/Text';
16
+
17
+ import type { NotificationMessages } from '../messages';
18
+ import { NOTIFICATION_CHANNELS, type NotificationChannel } from '../types';
19
+
20
+ import type { NotificationsApiClient, PreferencesPayload } from './api';
21
+ import { WebPushDeviceSetup, type WebPushSetupConfig } from './web-push-setup';
22
+
23
+ type Availability = Record<NotificationChannel, boolean>;
24
+
25
+ /** One category's row of channel switches. */
26
+ function CategoryCard({
27
+ category,
28
+ channels,
29
+ availability,
30
+ messages,
31
+ onToggle,
32
+ }: {
33
+ category: string;
34
+ channels: Record<NotificationChannel, boolean>;
35
+ availability: Availability;
36
+ messages: NotificationMessages;
37
+ onToggle: (channel: NotificationChannel, enabled: boolean) => void;
38
+ }): JSX.Element {
39
+ const labels = messages.categoryLabels[category];
40
+ return (
41
+ <Box
42
+ sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
43
+ data-testid={`prefs-${category}`}
44
+ >
45
+ <Text variant="body" size="sm" weight="semibold" as="p">
46
+ {labels?.title ?? messages.categoryFallbackTitle(category)}
47
+ </Text>
48
+ {labels?.description ? (
49
+ <Text variant="caption" size="xs" color="secondary" as="p">
50
+ {labels.description}
51
+ </Text>
52
+ ) : null}
53
+ <Box
54
+ sx={{
55
+ mt: 1.5,
56
+ display: 'grid',
57
+ gridTemplateColumns: { xs: '1fr 1fr', sm: 'repeat(4, 1fr)' },
58
+ gap: 1,
59
+ }}
60
+ >
61
+ {NOTIFICATION_CHANNELS.map((channel) => (
62
+ <Switch
63
+ key={channel}
64
+ size="sm"
65
+ color="primary"
66
+ label={messages.channelLabels[channel] ?? channel}
67
+ // An unavailable channel reads OFF regardless of the stored choice:
68
+ // a toggle that says "on" for a channel that cannot reach you is a
69
+ // promise the pipeline will not keep.
70
+ checked={channels[channel] && availability[channel]}
71
+ disabled={!availability[channel]}
72
+ onChange={(_, checked) => onToggle(channel, checked)}
73
+ // `dataTestId`, not `data-testid`: the UI Switch puts this one on
74
+ // the INPUT (and derives `-container` / `-label` from it), which is
75
+ // the element a click and a `disabled` assertion need.
76
+ dataTestId={`prefs-${category}-${channel}`}
77
+ />
78
+ ))}
79
+ </Box>
80
+ </Box>
81
+ );
82
+ }
83
+
84
+ /** Why disabled toggles are disabled, one line per unavailable channel. */
85
+ function UnavailableHints({
86
+ availability,
87
+ messages,
88
+ }: {
89
+ availability: Availability;
90
+ messages: NotificationMessages;
91
+ }): JSX.Element | null {
92
+ const unavailable = NOTIFICATION_CHANNELS.filter((channel) => !availability[channel]);
93
+ if (unavailable.length === 0) return null;
94
+ return (
95
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
96
+ {unavailable.map((channel) => (
97
+ <Text
98
+ key={channel}
99
+ variant="caption"
100
+ size="xs"
101
+ color="secondary"
102
+ as="p"
103
+ data-testid={`prefs-hint-${channel}`}
104
+ >
105
+ {messages.channelLabels[channel] ?? channel}:{' '}
106
+ {messages.channelUnavailableHints[channel] ?? ''}
107
+ </Text>
108
+ ))}
109
+ </Box>
110
+ );
111
+ }
112
+
113
+ export interface PreferencesScreenProps {
114
+ /** Rendered under the lead paragraph — a "back to account" link, typically. */
115
+ footer?: JSX.Element;
116
+ }
117
+
118
+ /** Optimistic per-toggle auto-save; a failed PUT takes the server's answer. */
119
+ function usePreferences(api: NotificationsApiClient): {
120
+ payload: PreferencesPayload | null;
121
+ toggle: (category: string, channel: NotificationChannel, enabled: boolean) => void;
122
+ } {
123
+ const [payload, setPayload] = useState<PreferencesPayload | null>(null);
124
+
125
+ useEffect(() => {
126
+ let cancelled = false;
127
+ void api
128
+ .getPreferences()
129
+ .then((next) => {
130
+ if (!cancelled) setPayload(next);
131
+ })
132
+ .catch(() => undefined);
133
+ return () => {
134
+ cancelled = true;
135
+ };
136
+ }, [api]);
137
+
138
+ const toggle = useCallback(
139
+ (category: string, channel: NotificationChannel, enabled: boolean) => {
140
+ setPayload((current) => {
141
+ const row = current?.preferences[category];
142
+ if (!current || !row) return current;
143
+ return {
144
+ ...current,
145
+ preferences: { ...current.preferences, [category]: { ...row, [channel]: enabled } },
146
+ };
147
+ });
148
+ const reconcile = (): void => {
149
+ void api.getPreferences().then(setPayload).catch(() => undefined);
150
+ };
151
+ void api
152
+ .savePreference(category, channel, enabled)
153
+ .then((result) => {
154
+ // The PUT answers with the whole matrix, so a success REPLACES the
155
+ // optimistic guess with the server's own row — which is what catches a
156
+ // save the server merged differently from the way the screen assumed.
157
+ if (result.ok) setPayload(result.data);
158
+ else reconcile();
159
+ })
160
+ // The packaged transport never rejects (it folds a failure into
161
+ // `ok: false`), but a HOST transport may — and an unhandled rejection
162
+ // would leave the toggle showing a choice the server never took.
163
+ .catch(reconcile);
164
+ },
165
+ [api],
166
+ );
167
+
168
+ return { payload, toggle };
169
+ }
170
+
171
+ export function PreferencesScreen({
172
+ footer,
173
+ api,
174
+ messages,
175
+ webPush,
176
+ }: PreferencesScreenProps & {
177
+ api: NotificationsApiClient;
178
+ messages: NotificationMessages;
179
+ webPush: WebPushSetupConfig;
180
+ }): JSX.Element {
181
+ const { payload, toggle } = usePreferences(api);
182
+
183
+ if (!payload) {
184
+ return (
185
+ <LoadingState
186
+ variant="spinner"
187
+ message={messages.loadingMore}
188
+ size="md"
189
+ dataTestId="notification-prefs-loading"
190
+ />
191
+ );
192
+ }
193
+
194
+ const { preferences, availability, categories } = payload;
195
+ return (
196
+ <Box
197
+ component="section"
198
+ data-testid="notification-prefs-view"
199
+ sx={{
200
+ maxWidth: 640,
201
+ mx: 'auto',
202
+ width: '100%',
203
+ px: 2,
204
+ py: 4,
205
+ display: 'flex',
206
+ flexDirection: 'column',
207
+ gap: 2,
208
+ }}
209
+ >
210
+ <Box>
211
+ <Text variant="heading" size="lg" as="h1">
212
+ {messages.preferencesTitle}
213
+ </Text>
214
+ <Text variant="caption" size="sm" color="secondary" as="p">
215
+ {messages.preferencesLead} {footer}
216
+ </Text>
217
+ </Box>
218
+ <WebPushDeviceSetup
219
+ available={availability.WEB_PUSH}
220
+ api={api}
221
+ messages={messages}
222
+ config={webPush}
223
+ />
224
+ {categories.map((category) => (
225
+ <CategoryCard
226
+ key={category}
227
+ category={category}
228
+ channels={preferences[category] ?? {
229
+ EMAIL: false,
230
+ SMS: false,
231
+ WHATSAPP: false,
232
+ WEB_PUSH: false,
233
+ }}
234
+ availability={availability}
235
+ messages={messages}
236
+ onToggle={(channel, enabled) => toggle(category, channel, enabled)}
237
+ />
238
+ ))}
239
+ <UnavailableHints availability={availability} messages={messages} />
240
+ </Box>
241
+ );
242
+ }
@@ -0,0 +1,18 @@
1
+ import type { NotificationMessages } from '../messages';
2
+
3
+ /**
4
+ * "há 5 min"-style relative timestamp, falling back to an absolute date for
5
+ * anything older than a week. Every word comes from the messages table, so a
6
+ * host in another locale changes the copy and the locale together.
7
+ */
8
+ export function relativeTime(iso: string, messages: NotificationMessages): string {
9
+ const elapsedMs = Date.now() - new Date(iso).getTime();
10
+ const minutes = Math.round(elapsedMs / 60_000);
11
+ if (minutes < 1) return messages.justNow;
12
+ if (minutes < 60) return messages.minutesAgo(minutes);
13
+ const hours = Math.round(minutes / 60);
14
+ if (hours < 24) return messages.hoursAgo(hours);
15
+ const days = Math.round(hours / 24);
16
+ if (days < 7) return messages.daysAgo(days);
17
+ return new Date(iso).toLocaleDateString(messages.dateLocale);
18
+ }
@@ -0,0 +1,98 @@
1
+ /** One inbox row: unread accent, content (opens/marks read), timestamp, delete. */
2
+ import type { JSX } from 'react';
3
+
4
+ import { Button } from '@12-apps/ui/form/Button';
5
+ import { Box } from '@12-apps/ui/mui/Box';
6
+ import { alpha, type Theme } from '@12-apps/ui/mui/styles';
7
+ import { Text } from '@12-apps/ui/typography/Text';
8
+
9
+ import type { NotificationMessages } from '../messages';
10
+ import type { InboxNotification } from '../wire';
11
+
12
+ import { relativeTime } from './relative-time';
13
+
14
+ const contentButtonSx = {
15
+ flex: 1,
16
+ minWidth: 0,
17
+ display: 'flex',
18
+ flexDirection: 'column',
19
+ gap: 0.25,
20
+ textAlign: 'left',
21
+ border: 'none',
22
+ background: 'none',
23
+ p: 0,
24
+ cursor: 'pointer',
25
+ color: 'text.primary',
26
+ fontFamily: 'inherit',
27
+ } as const;
28
+
29
+ const unreadDotSx = {
30
+ width: 8,
31
+ height: 8,
32
+ borderRadius: '50%',
33
+ bgcolor: 'primary.main',
34
+ flex: '0 0 auto',
35
+ } as const;
36
+
37
+ export function NotificationRow({
38
+ notification,
39
+ messages,
40
+ onOpen,
41
+ onDelete,
42
+ }: {
43
+ notification: InboxNotification;
44
+ messages: NotificationMessages;
45
+ onOpen: (notification: InboxNotification) => void;
46
+ onDelete: (id: string) => void;
47
+ }): JSX.Element {
48
+ const unread = notification.readAt === null;
49
+ return (
50
+ <Box
51
+ data-testid={`notification-${notification.id}`}
52
+ sx={{
53
+ display: 'flex',
54
+ alignItems: 'flex-start',
55
+ gap: 1,
56
+ py: 1.5,
57
+ px: 1,
58
+ borderBottom: '1px solid',
59
+ borderColor: 'divider',
60
+ bgcolor: unread ? (t: Theme) => alpha(t.palette.primary.main, 0.06) : 'transparent',
61
+ }}
62
+ >
63
+ <Box
64
+ component="button"
65
+ type="button"
66
+ onClick={() => onOpen(notification)}
67
+ aria-label={
68
+ unread ? `${notification.title} (${messages.unreadSuffix})` : notification.title
69
+ }
70
+ sx={contentButtonSx}
71
+ >
72
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
73
+ {unread ? <Box aria-hidden sx={unreadDotSx} /> : null}
74
+ <Text variant="body" size="sm" weight={unread ? 'bold' : 'medium'} as="span">
75
+ {notification.title}
76
+ </Text>
77
+ </Box>
78
+ <Text variant="caption" size="xs" color="secondary" as="span">
79
+ {notification.body}
80
+ </Text>
81
+ <Text variant="caption" size="xs" color="secondary" as="span" italic>
82
+ {relativeTime(notification.createdAt, messages)}
83
+ </Text>
84
+ </Box>
85
+
86
+ <Button
87
+ variant="ghost"
88
+ color="neutral"
89
+ size="xs"
90
+ aria-label={messages.deleteOne(notification.title)}
91
+ onClick={() => onDelete(notification.id)}
92
+ dataTestId={`notification-delete-${notification.id}`}
93
+ >
94
+ ✕
95
+ </Button>
96
+ </Box>
97
+ );
98
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * How the notification screens reach their data (12-15) — the report-builder
3
+ * transport doctrine: this is the ONLY way the surface performs I/O, so a
4
+ * caller supplying one has substituted the entire backend without stubbing a
5
+ * global. The default is same-origin `fetch` riding the browser's cookies.
6
+ */
7
+
8
+ /** A write outcome the screens branch on — never a thrown mutation. */
9
+ export type NotificationsResult<T> = { ok: true; data: T } | { ok: false; error: string };
10
+
11
+ /** A failed read, carrying the status the screens branch on (401 = signed out). */
12
+ export class NotificationsHttpError extends Error {
13
+ readonly status: number;
14
+ constructor(status: number, message: string) {
15
+ super(message);
16
+ this.name = 'NotificationsHttpError';
17
+ this.status = status;
18
+ Object.setPrototypeOf(this, NotificationsHttpError.prototype);
19
+ }
20
+ }
21
+
22
+ export interface NotificationsTransport {
23
+ /** A read. Returns the payload INSIDE the `{ data }` envelope. */
24
+ get<T>(path: string): Promise<T>;
25
+ /** A write. Returns a {@link NotificationsResult} rather than rejecting. */
26
+ send<T>(path: string, method: string, body?: unknown): Promise<NotificationsResult<T>>;
27
+ }
28
+
29
+ const FALLBACK_ERROR = 'Não foi possível concluir a operação.';
30
+
31
+ export function httpNotificationsTransport(fallbackError = FALLBACK_ERROR): NotificationsTransport {
32
+ return {
33
+ async get<T>(path: string): Promise<T> {
34
+ const response = await fetch(path, {
35
+ credentials: 'same-origin',
36
+ headers: { Accept: 'application/json' },
37
+ });
38
+ const payload = (await response.json().catch(() => null)) as
39
+ | { data?: T; error?: string }
40
+ | null;
41
+ if (!response.ok) {
42
+ throw new NotificationsHttpError(
43
+ response.status,
44
+ payload?.error ?? `HTTP ${response.status} for ${path}`,
45
+ );
46
+ }
47
+ return (payload?.data ?? payload) as T;
48
+ },
49
+
50
+ async send<T>(path: string, method: string, body?: unknown): Promise<NotificationsResult<T>> {
51
+ try {
52
+ const response = await fetch(path, {
53
+ method,
54
+ credentials: 'same-origin',
55
+ headers: {
56
+ Accept: 'application/json',
57
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
58
+ },
59
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
60
+ });
61
+ if (response.status === 204) return { ok: true, data: undefined as T };
62
+ const payload = (await response.json().catch(() => null)) as
63
+ | { data?: T; error?: string }
64
+ | null;
65
+ if (!response.ok) return { ok: false, error: payload?.error ?? fallbackError };
66
+ return { ok: true, data: (payload?.data ?? payload) as T };
67
+ } catch {
68
+ return { ok: false, error: fallbackError };
69
+ }
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,113 @@
1
+ import type { NotificationsApiClient } from './api';
2
+
3
+ /**
4
+ * The browser half of Web Push: register the service worker, ask permission,
5
+ * subscribe with the deployment's VAPID public key (read from the packaged
6
+ * `GET <mount>/push-subscriptions`) and persist the subscription so the
7
+ * WEB_PUSH transport can reach this browser.
8
+ *
9
+ * A preference alone cannot reach a device that never subscribed, which is why
10
+ * this ships with the preferences screen rather than being left to the host.
11
+ * The one thing that IS the host's is the service-worker path — path-routed SPAs
12
+ * each control their own scope, and the file itself lives in the host's public
13
+ * directory.
14
+ */
15
+
16
+ /** Why enabling push failed, mapped to a user-facing hint by the caller. */
17
+ export type PushSetupResult =
18
+ | { ok: true }
19
+ | { ok: false; reason: 'unsupported' | 'unconfigured' | 'permission-denied' | 'error' };
20
+
21
+ /** `PushManager.subscribe` needs the VAPID key as a Uint8Array. */
22
+ function base64UrlToUint8Array(base64Url: string): Uint8Array {
23
+ const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
24
+ const base64 = (base64Url + padding).replaceAll('-', '+').replaceAll('_', '/');
25
+ const raw = atob(base64);
26
+ return Uint8Array.from(raw, (char) => char.charCodeAt(0));
27
+ }
28
+
29
+ export function pushSupported(): boolean {
30
+ return (
31
+ typeof navigator !== 'undefined' &&
32
+ 'serviceWorker' in navigator &&
33
+ typeof window !== 'undefined' &&
34
+ 'PushManager' in window &&
35
+ 'Notification' in window
36
+ );
37
+ }
38
+
39
+ /** Whether this browser currently holds an active push subscription. */
40
+ export async function getExistingPushSubscription(): Promise<PushSubscription | null> {
41
+ if (!pushSupported()) return null;
42
+ const registration = await navigator.serviceWorker.getRegistration();
43
+ if (!registration) return null;
44
+ return registration.pushManager.getSubscription();
45
+ }
46
+
47
+ /** Register the SW and return this browser's (possibly new) subscription. */
48
+ async function obtainSubscription(
49
+ swPath: string,
50
+ vapidPublicKey: string,
51
+ ): Promise<PushSubscription> {
52
+ const registration = await navigator.serviceWorker.register(swPath);
53
+ await navigator.serviceWorker.ready;
54
+ return (
55
+ (await registration.pushManager.getSubscription()) ??
56
+ registration.pushManager.subscribe({
57
+ userVisibleOnly: true,
58
+ applicationServerKey: base64UrlToUint8Array(vapidPublicKey) as BufferSource,
59
+ })
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Full enable flow: configured? → permission → SW registration → subscribe →
65
+ * persist. Idempotent — an existing subscription is simply re-persisted (the
66
+ * server upserts on the endpoint).
67
+ */
68
+ /** Persist the browser's subscription server-side (upsert on the endpoint). */
69
+ async function persist(
70
+ api: NotificationsApiClient,
71
+ subscription: PushSubscription,
72
+ ): Promise<PushSetupResult> {
73
+ const json = subscription.toJSON();
74
+ if (!json.endpoint || !json.keys?.p256dh || !json.keys.auth) {
75
+ return { ok: false, reason: 'error' };
76
+ }
77
+ const saved = await api.savePushSubscription({
78
+ endpoint: json.endpoint,
79
+ keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
80
+ });
81
+ return saved.ok ? { ok: true } : { ok: false, reason: 'error' };
82
+ }
83
+
84
+ export async function enableWebPush(
85
+ api: NotificationsApiClient,
86
+ swPath = '/sw.js',
87
+ ): Promise<PushSetupResult> {
88
+ if (!pushSupported()) return { ok: false, reason: 'unsupported' };
89
+
90
+ const registration = await api.getPushRegistration().catch(() => null);
91
+ if (!registration?.vapidPublicKey) return { ok: false, reason: 'unconfigured' };
92
+
93
+ const permission = await Notification.requestPermission();
94
+ if (permission !== 'granted') return { ok: false, reason: 'permission-denied' };
95
+
96
+ try {
97
+ return await persist(
98
+ api,
99
+ await obtainSubscription(swPath, registration.vapidPublicKey),
100
+ );
101
+ } catch {
102
+ return { ok: false, reason: 'error' };
103
+ }
104
+ }
105
+
106
+ /** Disable flow: unsubscribe the browser and drop the server-side row. */
107
+ export async function disableWebPush(api: NotificationsApiClient): Promise<void> {
108
+ const subscription = await getExistingPushSubscription();
109
+ if (!subscription) return;
110
+ const endpoint = subscription.endpoint;
111
+ await subscription.unsubscribe().catch(() => false);
112
+ await api.removePushSubscription(endpoint).catch(() => undefined);
113
+ }