@marktiderman/genesis-notifications 0.0.0-stage → 0.1.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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -2
  3. package/dist/blocks.d.ts +10 -0
  4. package/dist/blocks.js +35 -0
  5. package/dist/blocks.js.map +1 -0
  6. package/dist/chunk-2BG336II.js +124 -0
  7. package/dist/chunk-2BG336II.js.map +1 -0
  8. package/dist/chunk-7PHA3WVG.js +51 -0
  9. package/dist/chunk-7PHA3WVG.js.map +1 -0
  10. package/dist/chunk-EWZB35V6.js +113 -0
  11. package/dist/chunk-EWZB35V6.js.map +1 -0
  12. package/dist/chunk-UXNWI7OS.js +118 -0
  13. package/dist/chunk-UXNWI7OS.js.map +1 -0
  14. package/dist/email-render-CID_pXDy.d.ts +53 -0
  15. package/dist/email.d.ts +46 -0
  16. package/dist/email.js +64 -0
  17. package/dist/email.js.map +1 -0
  18. package/dist/index.d.ts +26 -0
  19. package/dist/index.js +151 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/native.d.ts +92 -0
  22. package/dist/native.js +170 -0
  23. package/dist/native.js.map +1 -0
  24. package/dist/react.d.ts +98 -0
  25. package/dist/react.js +4 -0
  26. package/dist/react.js.map +1 -0
  27. package/dist/runner.d.ts +174 -0
  28. package/dist/runner.js +3 -0
  29. package/dist/runner.js.map +1 -0
  30. package/dist/server.d.ts +658 -0
  31. package/dist/server.js +995 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/supabase.d.ts +118 -0
  34. package/dist/supabase.js +3 -0
  35. package/dist/supabase.js.map +1 -0
  36. package/dist/types-BilguCPW.d.ts +332 -0
  37. package/dist/web.d.ts +79 -0
  38. package/dist/web.js +233 -0
  39. package/dist/web.js.map +1 -0
  40. package/functions/genesis-notifications/index.ts +60 -0
  41. package/functions/genesis-notifications/notifications.ts +18 -0
  42. package/migrations/0001_genesis_notifications.sql +2057 -0
  43. package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
  44. package/package.json +191 -3
  45. package/src/blocks.ts +42 -0
  46. package/src/core/index.ts +43 -0
  47. package/src/core/registry.ts +354 -0
  48. package/src/core/types.ts +90 -0
  49. package/src/core/validate.ts +73 -0
  50. package/src/email/index.tsx +142 -0
  51. package/src/index.ts +6 -0
  52. package/src/native/index.tsx +283 -0
  53. package/src/react/index.tsx +226 -0
  54. package/src/runner/index.ts +299 -0
  55. package/src/server/crypto.ts +44 -0
  56. package/src/server/dns.ts +106 -0
  57. package/src/server/email-render.ts +90 -0
  58. package/src/server/errors.ts +61 -0
  59. package/src/server/expo.ts +133 -0
  60. package/src/server/handler.ts +127 -0
  61. package/src/server/index.ts +57 -0
  62. package/src/server/notify.ts +100 -0
  63. package/src/server/rate.ts +47 -0
  64. package/src/server/resend.ts +143 -0
  65. package/src/server/runner.ts +524 -0
  66. package/src/server/secret-links.ts +53 -0
  67. package/src/server/store.ts +150 -0
  68. package/src/server/unsubscribe.ts +78 -0
  69. package/src/server/webhook.ts +55 -0
  70. package/src/supabase/index.ts +210 -0
  71. package/src/web/index.tsx +428 -0
@@ -0,0 +1,332 @@
1
+ /**
2
+ * `defineNotifications`: the one list of what an app notifies people about,
3
+ * as `defineFeatures` is the list of what it does. Each type names its
4
+ * category, the channels it may use (and which are on by default), and two
5
+ * flags: `critical` (preferences can't turn it off) and `transactional` (no
6
+ * unsubscribe link, no quiet hours, no digest). The registry also defines the
7
+ * device notification categories (iOS action buttons) and the Android
8
+ * channels the app makes on the phone.
9
+ *
10
+ * It is plain data plus small render functions, so the browser, the phone
11
+ * and the server import the same list. `toTypeRows()` is what is mirrored
12
+ * into the database's `notification_types`.
13
+ */
14
+ /** Where a notification can go. `in_app` is the feed. */
15
+ declare const CHANNELS: readonly ["in_app", "push", "email"];
16
+ type Channel = (typeof CHANNELS)[number];
17
+ /** Lower case letters, digits, ".", "_" or "-", up to 64 characters: type keys, category ids. */
18
+ declare const KEY_PATTERN: RegExp;
19
+ /** Device category and action ids: Expo asks for no ":" or "-". */
20
+ declare const IOS_ID_PATTERN: RegExp;
21
+ /** Android channel importance (Expo's `AndroidImportance`). */
22
+ type ChannelImportance = "min" | "low" | "default" | "high" | "max";
23
+ interface NotificationCategoryDef {
24
+ /** What a person sees in their preferences ("Messages"). */
25
+ label: string;
26
+ description?: string;
27
+ /** Preferences can't turn this category off (account security, say). */
28
+ critical?: boolean;
29
+ /** The Android channel made for it (its id is the category id). Default importance `"default"`. */
30
+ android?: {
31
+ importance?: ChannelImportance;
32
+ name?: string;
33
+ };
34
+ }
35
+ /** An action button on a notification. */
36
+ interface NotificationActionDef {
37
+ id: string;
38
+ title: string;
39
+ /** A reply field instead of a plain button. */
40
+ textInput?: {
41
+ placeholder?: string;
42
+ submitTitle?: string;
43
+ };
44
+ /** Opens the app (default false: the action is handled in the background). */
45
+ opensApp?: boolean;
46
+ destructive?: boolean;
47
+ /** Needs the phone unlocked. */
48
+ requiresAuth?: boolean;
49
+ }
50
+ /** A notification category on the device (iOS's word): the actions a notification shows. */
51
+ interface NotificationActionSetDef {
52
+ actions: readonly NotificationActionDef[];
53
+ /** Shown when the person has hidden previews for the app. */
54
+ previewPlaceholder?: string;
55
+ }
56
+ /** What a notification says, made from its `data`. */
57
+ interface RenderedText {
58
+ title: string;
59
+ body?: string;
60
+ /** The app path a tap opens (`/messages/42`). */
61
+ url?: string;
62
+ /**
63
+ * Push-only extras, for a type whose app reads more than the link: `ids` always travel
64
+ * (a Reply action needs the conversation and message), `display` only when previews are
65
+ * shown (a sender's name and photo for iOS rich push), and `mutableContent` lets the app's
66
+ * Notification Service Extension run (dropped with hidden previews). Flat values only; the
67
+ * notifier's own keys (`notification_id`, `type`, `url`, `thread_key`) can't be replaced.
68
+ */
69
+ push?: {
70
+ ids?: Record<string, string | number>;
71
+ display?: Record<string, string | number>;
72
+ mutableContent?: boolean;
73
+ };
74
+ }
75
+ /** A notification's `data`: references and display values, never a secret. */
76
+ type NotificationData = Record<string, unknown>;
77
+ interface NotificationTypeDef<C extends string = string, A extends string = string> {
78
+ category: C;
79
+ /**
80
+ * The channels this type may use, and whether each is on for someone who
81
+ * never chose: `{ in_app: true, push: true, email: false }` offers email
82
+ * as an opt-in. A channel left out is never used.
83
+ */
84
+ channels: Partial<Record<Channel, boolean>>;
85
+ /** Preferences can't turn it off: each channel it offers stays as defaulted. */
86
+ critical?: boolean;
87
+ /**
88
+ * Mail the person needs for something they did (a receipt, a reset): no
89
+ * unsubscribe link, never held by quiet hours or folded into a digest.
90
+ * Rate caps are the domain's.
91
+ */
92
+ transactional?: boolean;
93
+ /**
94
+ * Wait this long before emailing, and skip the email if the person read
95
+ * the notification in the app meanwhile. Default 0 (send at once).
96
+ */
97
+ emailDelaySeconds?: number;
98
+ /** The device category (a key of `actions`) its pushes carry. */
99
+ actions?: A;
100
+ /** `"generic"` always sends neutral push text, whatever the person chose. */
101
+ pushPrivacy?: "default" | "generic";
102
+ /**
103
+ * The email holds a secret link (a one-time token). It is never stored in
104
+ * `data` or pushed: the server builds it at send time with
105
+ * `resolveSecretLink(type, ref)`.
106
+ */
107
+ secretLink?: boolean;
108
+ /** What the feed and the push say. */
109
+ render: (data: NotificationData) => RenderedText;
110
+ }
111
+ interface NotificationsInput<C extends Record<string, NotificationCategoryDef>, A extends Record<string, NotificationActionSetDef>, T extends Record<string, NotificationTypeDef<Extract<keyof C, string>, Extract<keyof A, string>>>> {
112
+ categories: C;
113
+ actions?: A;
114
+ types: T;
115
+ /** Neutral text for hidden previews. Default "New notification". */
116
+ hiddenPreview?: {
117
+ title: string;
118
+ body?: string;
119
+ };
120
+ }
121
+ /** One row of `notification_types`, as `genesis_notifications_sync_types` takes it. */
122
+ interface NotificationTypeRow {
123
+ key: string;
124
+ category: string;
125
+ channels: Partial<Record<Channel, boolean>>;
126
+ critical: boolean;
127
+ transactional: boolean;
128
+ email_delay_seconds: number;
129
+ secret_link: boolean;
130
+ push_privacy: "default" | "generic";
131
+ ios_category: string | null;
132
+ }
133
+ interface MatrixRow {
134
+ id: string;
135
+ label: string;
136
+ description?: string;
137
+ critical: boolean;
138
+ /** The channels some type in the category offers, in `CHANNELS` order. */
139
+ channels: Channel[];
140
+ /** Each offered channel's default: on when any type in the category has it on. */
141
+ defaults: Partial<Record<Channel, boolean>>;
142
+ }
143
+ interface NotificationRegistry<C extends Record<string, NotificationCategoryDef> = Record<string, NotificationCategoryDef>, A extends Record<string, NotificationActionSetDef> = Record<string, NotificationActionSetDef>, T extends Record<string, NotificationTypeDef> = Record<string, NotificationTypeDef>> {
144
+ readonly categories: C;
145
+ readonly actions: A;
146
+ readonly types: T;
147
+ readonly hiddenPreview: {
148
+ title: string;
149
+ body?: string;
150
+ };
151
+ /** The type, or throws: an unknown key is a bug, never dropped quietly. */
152
+ type(key: string): NotificationTypeDef;
153
+ has(key: string): boolean;
154
+ /** Preferences can't turn it off: a critical type, or a type in a critical category. */
155
+ isCritical(key: string): boolean;
156
+ /** What the feed and the push say. A render that throws gives the type's key as the title. */
157
+ render(key: string, data: NotificationData): RenderedText;
158
+ /** The rows mirrored into `notification_types`. */
159
+ toTypeRows(): NotificationTypeRow[];
160
+ /** For a preferences matrix: each category with the channels its types offer. */
161
+ matrix(): MatrixRow[];
162
+ /** The device categories to register (`setNotificationCategoryAsync`). */
163
+ deviceCategories(): {
164
+ identifier: string;
165
+ actions: NotificationActionDef[];
166
+ previewPlaceholder?: string;
167
+ }[];
168
+ /** The Android channels, one per category. */
169
+ androidChannels(): {
170
+ id: string;
171
+ name: string;
172
+ description?: string;
173
+ importance: ChannelImportance;
174
+ }[];
175
+ }
176
+ /** Thrown by `defineNotifications`; lists every problem at once. */
177
+ declare class NotificationRegistryError extends Error {
178
+ readonly problems: string[];
179
+ constructor(problems: string[]);
180
+ }
181
+ /** The longest email delay: a week. */
182
+ declare const MAX_EMAIL_DELAY_SECONDS: number;
183
+ /**
184
+ * The notification registry.
185
+ *
186
+ * ```ts
187
+ * export const notifications = defineNotifications({
188
+ * categories: { messages: { label: "Messages", android: { importance: "high" } } },
189
+ * actions: { message: { actions: [{ id: "reply", title: "Reply", textInput: {} }, { id: "mark_read", title: "Mark as read" }] } },
190
+ * types: {
191
+ * "chat.message": {
192
+ * category: "messages",
193
+ * channels: { in_app: true, push: true, email: true },
194
+ * emailDelaySeconds: 600,
195
+ * actions: "message",
196
+ * render: (d) => ({ title: String(d.sender_name), body: String(d.preview), url: `/messages/${d.conversation_id}` }),
197
+ * },
198
+ * },
199
+ * });
200
+ * ```
201
+ *
202
+ * @stability Beta
203
+ */
204
+ declare function defineNotifications<const C extends Record<string, NotificationCategoryDef>, const A extends Record<string, NotificationActionSetDef>, const T extends Record<string, NotificationTypeDef<Extract<keyof C, string>, Extract<keyof A, string>>>>(input: NotificationsInput<C, A, T>): NotificationRegistry<C, A, T>;
205
+ /**
206
+ * The actions a message notification shows: Reply (a text field) and Mark as
207
+ * read. Put it in `actions` under an id (`message`) and name that id in the
208
+ * message type's `actions`. A tap on either reaches the push block's
209
+ * `onResponse`; `notificationAction(response)` gives the action id, the typed
210
+ * text and the notification id (use it as the reply's client id, so a
211
+ * retried reply stays one reply).
212
+ */
213
+ declare const MESSAGE_ACTIONS: {
214
+ readonly actions: readonly [{
215
+ readonly id: "reply";
216
+ readonly title: "Reply";
217
+ readonly textInput: {
218
+ readonly placeholder: "Message";
219
+ readonly submitTitle: "Send";
220
+ };
221
+ }, {
222
+ readonly id: "mark_read";
223
+ readonly title: "Mark as read";
224
+ }];
225
+ };
226
+ /** The category and type of Admin -> Send a test (`genesis.test`, which the migration creates). Spread them into your registry. */
227
+ declare const GENESIS_TEST_CATEGORY: {
228
+ readonly genesis: {
229
+ readonly label: "Tests";
230
+ readonly description: "Test notifications an admin sends themself.";
231
+ };
232
+ };
233
+ declare const GENESIS_TEST_TYPE: {
234
+ readonly "genesis.test": {
235
+ readonly category: "genesis";
236
+ readonly channels: {
237
+ readonly in_app: true;
238
+ readonly push: true;
239
+ readonly email: true;
240
+ };
241
+ readonly critical: true;
242
+ readonly transactional: true;
243
+ readonly render: () => {
244
+ title: string;
245
+ body: string;
246
+ };
247
+ };
248
+ };
249
+
250
+ /**
251
+ * The shapes the notifier's parts share: what a domain passes to `notify()`,
252
+ * a feed row as the database returns it, and a person's preferences.
253
+ */
254
+
255
+ /** A recipient with a login, by user id. */
256
+ interface UserRecipient {
257
+ userId: string;
258
+ /** Their own app icon badge (an unread count), over the call's `badge`. */
259
+ badge?: number;
260
+ }
261
+ /**
262
+ * Someone with no login (an invitee, a form respondent): email only, never a
263
+ * feed. `ref` is the domain's id for them ("people:42"), so erasing them
264
+ * finds their rows.
265
+ */
266
+ interface EmailRecipient {
267
+ email: string;
268
+ name?: string;
269
+ ref?: string;
270
+ }
271
+ /** A user id string, or one of the two shapes. */
272
+ type Recipient = string | UserRecipient | EmailRecipient;
273
+ /** A brand the caller passes (the tenant's default is used for what it leaves out). */
274
+ interface NotificationBrand {
275
+ name?: string;
276
+ logoUrl?: string;
277
+ /** A CSS colour (`#1d4ed8`), for the email's accent. */
278
+ color?: string;
279
+ /** Where a recipient goes from the email's footer. */
280
+ url?: string;
281
+ }
282
+ /** What a domain asks for. The notifier decides channel, timing and preferences. */
283
+ interface NotifyInput<K extends string = string> {
284
+ type: K;
285
+ recipients: readonly Recipient[];
286
+ /** References and display values the type's render reads. Never a secret or a secret link. */
287
+ data?: NotificationData;
288
+ /** Unique per recipient: the same key again is the same notification, never a second one. */
289
+ idempotencyKey: string;
290
+ /** Groups pushes on the lock screen (iOS `threadId`): a conversation id. */
291
+ threadKey?: string;
292
+ /** A newer one replaces an older one with the same key that hasn't gone out (and on screen). */
293
+ collapseKey?: string;
294
+ /** The app icon badge, as the caller counts it. */
295
+ badge?: number;
296
+ /** The practice, workspace or organization it belongs to: its sender identity and defaults apply. */
297
+ tenantId?: string;
298
+ brand?: NotificationBrand;
299
+ /**
300
+ * A plaintext token that exists only hashed in the domain's tables (an
301
+ * invite). It travels only on the email's send job, encrypted, and is
302
+ * cleared once the email is sent; `resolveSecretLink` receives it.
303
+ */
304
+ secret?: string;
305
+ }
306
+ /** One row `notify()` made or found again. */
307
+ interface NotifyResult {
308
+ notificationId: string;
309
+ recipientKey: string;
310
+ /** False when the idempotency key had been used for this recipient already. */
311
+ created: boolean;
312
+ }
313
+ /** A feed row, as the browser reads it under row level security. */
314
+ interface NotificationRow {
315
+ id: string;
316
+ type: string;
317
+ category: string;
318
+ tenant_id: string | null;
319
+ data: NotificationData;
320
+ thread_key: string | null;
321
+ created_at: string;
322
+ seen_at: string | null;
323
+ read_at: string | null;
324
+ archived_at: string | null;
325
+ }
326
+ /** Delivery states. */
327
+ declare const DELIVERY_STATUSES: readonly ["pending", "sending", "sent", "delivered", "failed", "skipped", "cancelled"];
328
+ type DeliveryStatus = (typeof DELIVERY_STATUSES)[number];
329
+ /** A person's explicit choices: category id → channel → on. Absent means the default. */
330
+ type ChannelChoices = Record<string, Partial<Record<Channel, boolean>>>;
331
+
332
+ export { CHANNELS as C, DELIVERY_STATUSES as D, type EmailRecipient as E, GENESIS_TEST_CATEGORY as G, IOS_ID_PATTERN as I, KEY_PATTERN as K, MAX_EMAIL_DELAY_SECONDS as M, type NotificationData as N, type Recipient as R, type UserRecipient as U, type Channel as a, type ChannelChoices as b, type ChannelImportance as c, type DeliveryStatus as d, GENESIS_TEST_TYPE as e, MESSAGE_ACTIONS as f, type MatrixRow as g, type NotificationActionDef as h, type NotificationActionSetDef as i, type NotificationBrand as j, type NotificationCategoryDef as k, type NotificationRegistry as l, NotificationRegistryError as m, type NotificationRow as n, type NotificationTypeDef as o, type NotificationTypeRow as p, type NotificationsInput as q, type NotifyInput as r, type NotifyResult as s, type RenderedText as t, defineNotifications as u };
package/dist/web.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { a as Channel } from './types-BilguCPW.js';
4
+ import { FeedItem } from './react.js';
5
+ import './supabase.js';
6
+
7
+ declare const CHANNEL_LABELS: Record<Channel, string>;
8
+ interface FeedRowProps {
9
+ item: FeedItem;
10
+ onOpen?: (item: FeedItem) => void;
11
+ testID?: string;
12
+ }
13
+ /** One feed row: unread dot, title, body, age. */
14
+ declare function NotificationRowView({ item, onOpen, testID }: FeedRowProps): react.JSX.Element;
15
+ interface NotificationBellProps {
16
+ /** Opens a notification's link (your router's navigate). Marked read first. */
17
+ onOpen?: (item: FeedItem) => void;
18
+ /** Where "See all" goes. */
19
+ onSeeAll?: () => void;
20
+ /** How many to show. Default 8. */
21
+ limit?: number;
22
+ className?: string;
23
+ }
24
+ /**
25
+ * The bell. Opening it marks what it shows as seen; opening a row marks it read.
26
+ *
27
+ * @stability Beta
28
+ */
29
+ declare function NotificationBell({ onOpen, onSeeAll, limit, className }: NotificationBellProps): react.JSX.Element;
30
+ interface NotificationFeedProps {
31
+ onOpen?: (item: FeedItem) => void;
32
+ limit?: number;
33
+ title?: ReactNode;
34
+ }
35
+ /**
36
+ * The whole feed, in a card: mark read on open, archive, mark all read.
37
+ *
38
+ * @stability Beta
39
+ */
40
+ declare function NotificationFeed({ onOpen, limit, title }: NotificationFeedProps): react.JSX.Element;
41
+ /**
42
+ * Categories × channels, a switch each. Critical categories are locked on.
43
+ * Saves each change at once and puts it back if the save fails.
44
+ *
45
+ * @stability Beta
46
+ */
47
+ declare function PreferencesMatrix({ channelLabels }: {
48
+ channelLabels?: Partial<Record<Channel, string>>;
49
+ }): react.JSX.Element;
50
+ /**
51
+ * Admin: the delivery log, newest first. Row level security shows an admin
52
+ * their tenants' rows only; content and full addresses are never here.
53
+ *
54
+ * @stability Beta
55
+ */
56
+ declare function DeliveryLog({ tenantId, limit }: {
57
+ tenantId?: string | null;
58
+ limit?: number;
59
+ }): react.JSX.Element;
60
+ /**
61
+ * Admin: addresses the app won't email (by a masked address; the list keeps
62
+ * only a hash), and lifting one.
63
+ *
64
+ * @stability Beta
65
+ */
66
+ declare function SuppressionList({ limit }: {
67
+ limit?: number;
68
+ }): react.JSX.Element;
69
+ /**
70
+ * Admin: send yourself a test notification (in the app, push and email).
71
+ *
72
+ * @stability Beta
73
+ */
74
+ declare function SendTestNotification({ tenantId, label }: {
75
+ tenantId?: string | null;
76
+ label?: string;
77
+ }): react.JSX.Element;
78
+
79
+ export { CHANNEL_LABELS, DeliveryLog, type FeedRowProps, NotificationBell, type NotificationBellProps, NotificationFeed, type NotificationFeedProps, NotificationRowView, PreferencesMatrix, SendTestNotification, SuppressionList };
package/dist/web.js ADDED
@@ -0,0 +1,233 @@
1
+ import { useUnreadCount, useNotifications, usePreferences, useNotificationsContext } from './chunk-2BG336II.js';
2
+ import './chunk-EWZB35V6.js';
3
+ import { useState, useEffect } from 'react';
4
+ import { Bell } from 'lucide-react';
5
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
6
+ import { Button, cn, Popover, PopoverTrigger, Badge, PopoverContent, Separator, EmptyState, ScrollArea, Card, CardHeader, CardTitle, CardContent, Table, TableHeader, TableRow, TableHead, TableBody, TableCell, Switch, toast, CardDescription } from '@marktiderman/genesis-ui';
7
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
8
+
9
+ var CHANNEL_LABELS = { in_app: "In the app", push: "Push", email: "Email" };
10
+ function relative(iso, now = Date.now()) {
11
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1e3));
12
+ if (s < 60) return "just now";
13
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
14
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
15
+ return new Date(iso).toLocaleDateString();
16
+ }
17
+ function NotificationRowView({ item, onOpen, testID }) {
18
+ return /* @__PURE__ */ jsxs(
19
+ Button,
20
+ {
21
+ variant: "ghost",
22
+ "data-testid": testID ?? `notification-${item.id}`,
23
+ onClick: () => onOpen?.(item),
24
+ className: cn("h-auto w-full items-start justify-start gap-3 whitespace-normal px-3 py-2 text-left font-normal", item.unread && "bg-muted/40"),
25
+ children: [
26
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", item.unread ? "bg-primary" : "bg-transparent") }),
27
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
28
+ /* @__PURE__ */ jsx("span", { className: cn("block truncate text-sm", item.unread ? "font-semibold" : "font-medium"), children: item.title }),
29
+ item.body ? /* @__PURE__ */ jsx("span", { className: "block truncate text-sm text-muted-foreground", children: item.body }) : null
30
+ ] }),
31
+ /* @__PURE__ */ jsx("span", { className: "shrink-0 text-xs text-muted-foreground", children: relative(item.created_at) }),
32
+ item.unread ? /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Unread" }) : null
33
+ ]
34
+ }
35
+ );
36
+ }
37
+ function NotificationBell({ onOpen, onSeeAll, limit = 8, className }) {
38
+ const unread = useUnreadCount();
39
+ const { items, markRead, markAllRead, markAllSeen } = useNotifications({ limit });
40
+ const label = unread > 0 ? `Notifications, ${unread} unread` : "Notifications";
41
+ return /* @__PURE__ */ jsxs(Popover, { onOpenChange: (open) => open && items.some((i) => !i.seen_at) ? void markAllSeen() : void 0, children: [
42
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "icon", "aria-label": label, "data-testid": "notification-bell", className: cn("relative", className), children: [
43
+ /* @__PURE__ */ jsx(Bell, { "aria-hidden": true, className: "h-5 w-5" }),
44
+ unread > 0 ? /* @__PURE__ */ jsx(Badge, { "data-testid": "notification-bell-count", className: "absolute -right-1 -top-1 h-5 min-w-5 justify-center px-1 text-xs", children: unread > 99 ? "99+" : unread }) : null
45
+ ] }) }),
46
+ /* @__PURE__ */ jsxs(PopoverContent, { align: "end", className: "w-80 p-0", children: [
47
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-3 py-2", children: [
48
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-semibold", children: "Notifications" }),
49
+ unread > 0 ? /* @__PURE__ */ jsx(Button, { variant: "link", size: "sm", className: "h-auto p-0", "data-testid": "notification-bell-mark-all", onClick: () => void markAllRead(), children: "Mark all read" }) : null
50
+ ] }),
51
+ /* @__PURE__ */ jsx(Separator, {}),
52
+ items.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { title: "No notifications", description: "When you get one, it'll show up here.", className: "py-8" }) : /* @__PURE__ */ jsx(ScrollArea, { className: "max-h-96", children: /* @__PURE__ */ jsx("div", { className: "p-1", children: items.map((item) => /* @__PURE__ */ jsx(
53
+ NotificationRowView,
54
+ {
55
+ item,
56
+ onOpen: (i) => {
57
+ if (i.unread) void markRead(i.id);
58
+ onOpen?.(i);
59
+ }
60
+ },
61
+ item.id
62
+ )) }) }),
63
+ onSeeAll ? /* @__PURE__ */ jsxs(Fragment, { children: [
64
+ /* @__PURE__ */ jsx(Separator, {}),
65
+ /* @__PURE__ */ jsx(Button, { variant: "ghost", className: "w-full rounded-t-none", "data-testid": "notification-bell-see-all", onClick: onSeeAll, children: "See all" })
66
+ ] }) : null
67
+ ] })
68
+ ] });
69
+ }
70
+ function NotificationFeed({ onOpen, limit = 100, title = "Notifications" }) {
71
+ const { items, isLoading, error, markRead, markAllRead, archive } = useNotifications({ limit });
72
+ const anyUnread = items.some((i) => i.unread);
73
+ return /* @__PURE__ */ jsxs(Card, { "data-testid": "notification-feed", children: [
74
+ /* @__PURE__ */ jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [
75
+ /* @__PURE__ */ jsx(CardTitle, { children: title }),
76
+ anyUnread ? /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", "data-testid": "notification-feed-mark-all", onClick: () => void markAllRead(), children: "Mark all read" }) : null
77
+ ] }),
78
+ /* @__PURE__ */ jsx(CardContent, { className: "p-2", children: error ? /* @__PURE__ */ jsx(EmptyState, { title: "Couldn't load notifications", description: "Check your connection and try again." }) : isLoading ? /* @__PURE__ */ jsx("p", { className: "p-4 text-sm text-muted-foreground", children: "Loading\u2026" }) : items.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { title: "No notifications", description: "When you get one, it'll show up here." }) : /* @__PURE__ */ jsx("ul", { className: "divide-y divide-border", children: items.map((item) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-2", children: [
79
+ /* @__PURE__ */ jsx(
80
+ NotificationRowView,
81
+ {
82
+ item,
83
+ onOpen: (i) => {
84
+ if (i.unread) void markRead(i.id);
85
+ onOpen?.(i);
86
+ }
87
+ }
88
+ ),
89
+ /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", "aria-label": `Archive ${item.title}`, "data-testid": `notification-archive-${item.id}`, onClick: () => void archive(item.id), children: "Archive" })
90
+ ] }, item.id)) }) })
91
+ ] });
92
+ }
93
+ function PreferencesMatrix({ channelLabels = CHANNEL_LABELS }) {
94
+ const { rows, status, set } = usePreferences();
95
+ const columns = ["in_app", "push", "email"].filter((ch) => rows.some((r) => r.channels.includes(ch)));
96
+ if (status === "error") return /* @__PURE__ */ jsx(EmptyState, { title: "Couldn't load your notification settings", description: "Check your connection and try again." });
97
+ return /* @__PURE__ */ jsxs(Table, { "data-testid": "notification-preferences", children: [
98
+ /* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsxs(TableRow, { children: [
99
+ /* @__PURE__ */ jsx(TableHead, { children: "Notify me about" }),
100
+ columns.map((ch) => /* @__PURE__ */ jsx(TableHead, { className: "text-center", children: channelLabels[ch] ?? CHANNEL_LABELS[ch] }, ch))
101
+ ] }) }),
102
+ /* @__PURE__ */ jsx(TableBody, { children: rows.map((row) => /* @__PURE__ */ jsxs(TableRow, { children: [
103
+ /* @__PURE__ */ jsxs(TableCell, { children: [
104
+ /* @__PURE__ */ jsx("div", { className: "font-medium", children: row.label }),
105
+ row.description ? /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: row.description }) : null,
106
+ row.critical ? /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: "Always on" }) : null
107
+ ] }),
108
+ columns.map((ch) => /* @__PURE__ */ jsx(TableCell, { className: "text-center", children: row.channels.includes(ch) ? /* @__PURE__ */ jsx(
109
+ Switch,
110
+ {
111
+ "aria-label": `${row.label}: ${channelLabels[ch] ?? CHANNEL_LABELS[ch]}`,
112
+ "data-testid": `notification-preference-${row.id}-${ch}`,
113
+ checked: Boolean(row.values[ch]),
114
+ disabled: status !== "ready" || row.critical,
115
+ onCheckedChange: (on) => void set(row.id, ch, on).catch(() => toast.error("Couldn't save that change. Check your connection and try again."))
116
+ }
117
+ ) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", "aria-label": "Not available", children: "\u2013" }) }, ch))
118
+ ] }, row.id)) })
119
+ ] });
120
+ }
121
+ var STATUS_VARIANT = {
122
+ delivered: "success",
123
+ sent: "success",
124
+ pending: "info",
125
+ sending: "info",
126
+ failed: "destructive",
127
+ skipped: "muted",
128
+ cancelled: "muted"
129
+ };
130
+ function DeliveryLog({ tenantId, limit = 100 }) {
131
+ const { client, userId } = useNotificationsContext();
132
+ const query = useQuery({ queryKey: ["genesis-notifications", userId, "log", tenantId ?? "all", limit], queryFn: () => client.deliveryLog({ tenantId, limit }), enabled: userId !== null });
133
+ const rows = query.data ?? [];
134
+ return /* @__PURE__ */ jsxs(Card, { "data-testid": "notification-delivery-log", children: [
135
+ /* @__PURE__ */ jsxs(CardHeader, { children: [
136
+ /* @__PURE__ */ jsx(CardTitle, { children: "Delivery log" }),
137
+ /* @__PURE__ */ jsx(CardDescription, { children: "Every push and email the app sent, and what became of it." })
138
+ ] }),
139
+ /* @__PURE__ */ jsx(CardContent, { children: query.isError ? /* @__PURE__ */ jsx(EmptyState, { title: "Couldn't load the log", description: "You may not be an admin of this workspace." }) : rows.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { title: "Nothing sent yet" }) : /* @__PURE__ */ jsxs(Table, { children: [
140
+ /* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsxs(TableRow, { children: [
141
+ /* @__PURE__ */ jsx(TableHead, { children: "When" }),
142
+ /* @__PURE__ */ jsx(TableHead, { children: "Type" }),
143
+ /* @__PURE__ */ jsx(TableHead, { children: "Channel" }),
144
+ /* @__PURE__ */ jsx(TableHead, { children: "To" }),
145
+ /* @__PURE__ */ jsx(TableHead, { children: "Status" }),
146
+ /* @__PURE__ */ jsx(TableHead, { children: "Detail" })
147
+ ] }) }),
148
+ /* @__PURE__ */ jsx(TableBody, { children: rows.map((r) => /* @__PURE__ */ jsxs(TableRow, { "data-testid": `notification-delivery-${r.id}`, children: [
149
+ /* @__PURE__ */ jsx(TableCell, { className: "whitespace-nowrap text-sm", children: new Date(r.created_at).toLocaleString() }),
150
+ /* @__PURE__ */ jsx(TableCell, { className: "font-mono text-xs", children: r.type }),
151
+ /* @__PURE__ */ jsx(TableCell, { children: r.channel === "email" ? "Email" : "Push" }),
152
+ /* @__PURE__ */ jsx(TableCell, { className: "text-sm", children: r.recipient_masked ?? "\u2014" }),
153
+ /* @__PURE__ */ jsx(TableCell, { children: /* @__PURE__ */ jsx(Badge, { variant: STATUS_VARIANT[r.status] ?? "muted", children: r.status }) }),
154
+ /* @__PURE__ */ jsx(TableCell, { className: "text-sm text-muted-foreground", children: r.skip_reason ?? r.error ?? (r.attempts > 1 ? `${r.attempts} attempts` : "") })
155
+ ] }, r.id)) })
156
+ ] }) })
157
+ ] });
158
+ }
159
+ var REASONS = { bounce: "Bounced", complaint: "Marked as spam", unsubscribe: "Unsubscribed", manual: "Added by an admin" };
160
+ function SuppressionList({ limit = 200 }) {
161
+ const { client, userId } = useNotificationsContext();
162
+ const queryClient = useQueryClient();
163
+ const key = ["genesis-notifications", userId, "suppressions", limit];
164
+ const query = useQuery({ queryKey: key, queryFn: () => client.suppressions({ limit }), enabled: userId !== null });
165
+ const rows = query.data ?? [];
166
+ return /* @__PURE__ */ jsxs(Card, { "data-testid": "notification-suppressions", children: [
167
+ /* @__PURE__ */ jsxs(CardHeader, { children: [
168
+ /* @__PURE__ */ jsx(CardTitle, { children: "Suppressed addresses" }),
169
+ /* @__PURE__ */ jsx(CardDescription, { children: "Bounces and spam complaints stop email to an address; lift one once it's fixed." })
170
+ ] }),
171
+ /* @__PURE__ */ jsx(CardContent, { children: query.isError ? /* @__PURE__ */ jsx(EmptyState, { title: "Couldn't load the list", description: "Only the app's admins can see it." }) : rows.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { title: "No suppressed addresses" }) : /* @__PURE__ */ jsxs(Table, { children: [
172
+ /* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsxs(TableRow, { children: [
173
+ /* @__PURE__ */ jsx(TableHead, { children: "Address" }),
174
+ /* @__PURE__ */ jsx(TableHead, { children: "Why" }),
175
+ /* @__PURE__ */ jsx(TableHead, { children: "Category" }),
176
+ /* @__PURE__ */ jsx(TableHead, { children: "Since" }),
177
+ /* @__PURE__ */ jsx(TableHead, {})
178
+ ] }) }),
179
+ /* @__PURE__ */ jsx(TableBody, { children: rows.map((r) => /* @__PURE__ */ jsxs(TableRow, { children: [
180
+ /* @__PURE__ */ jsx(TableCell, { children: r.email_masked ?? `${r.email_hash.slice(0, 8)}\u2026` }),
181
+ /* @__PURE__ */ jsx(TableCell, { children: REASONS[r.reason] ?? r.reason }),
182
+ /* @__PURE__ */ jsx(TableCell, { children: r.category === "*" ? "All" : r.category }),
183
+ /* @__PURE__ */ jsx(TableCell, { className: "whitespace-nowrap text-sm", children: new Date(r.created_at).toLocaleDateString() }),
184
+ /* @__PURE__ */ jsx(TableCell, { className: "text-right", children: /* @__PURE__ */ jsx(
185
+ Button,
186
+ {
187
+ variant: "outline",
188
+ size: "sm",
189
+ "data-testid": `notification-suppression-lift-${r.email_hash.slice(0, 12)}`,
190
+ onClick: async () => {
191
+ try {
192
+ await client.removeSuppression(r.email_hash, r.category);
193
+ await queryClient.invalidateQueries({ queryKey: key });
194
+ } catch {
195
+ toast.error("Couldn't lift it. Try again.");
196
+ }
197
+ },
198
+ children: "Lift"
199
+ }
200
+ ) })
201
+ ] }, `${r.email_hash}:${r.category}`)) })
202
+ ] }) })
203
+ ] });
204
+ }
205
+ function SendTestNotification({ tenantId = null, label = "Send a test" }) {
206
+ const { client } = useNotificationsContext();
207
+ const [busy, setBusy] = useState(false);
208
+ const [mounted, setMounted] = useState(true);
209
+ useEffect(() => () => setMounted(false), []);
210
+ return /* @__PURE__ */ jsx(
211
+ Button,
212
+ {
213
+ "data-testid": "notification-send-test",
214
+ disabled: busy,
215
+ onClick: async () => {
216
+ setBusy(true);
217
+ try {
218
+ await client.sendTest(tenantId);
219
+ toast.success("Test sent. It reaches you on each channel you have on.");
220
+ } catch {
221
+ toast.error("Couldn't send the test. Only admins can.");
222
+ } finally {
223
+ if (mounted) setBusy(false);
224
+ }
225
+ },
226
+ children: label
227
+ }
228
+ );
229
+ }
230
+
231
+ export { CHANNEL_LABELS, DeliveryLog, NotificationBell, NotificationFeed, NotificationRowView, PreferencesMatrix, SendTestNotification, SuppressionList };
232
+ //# sourceMappingURL=web.js.map
233
+ //# sourceMappingURL=web.js.map