@marktiderman/genesis-notifications 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +251 -2
- package/dist/blocks.d.ts +10 -0
- package/dist/blocks.js +35 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-2BG336II.js +124 -0
- package/dist/chunk-2BG336II.js.map +1 -0
- package/dist/chunk-7PHA3WVG.js +51 -0
- package/dist/chunk-7PHA3WVG.js.map +1 -0
- package/dist/chunk-EWZB35V6.js +113 -0
- package/dist/chunk-EWZB35V6.js.map +1 -0
- package/dist/chunk-UXNWI7OS.js +118 -0
- package/dist/chunk-UXNWI7OS.js.map +1 -0
- package/dist/email-render-CID_pXDy.d.ts +53 -0
- package/dist/email.d.ts +46 -0
- package/dist/email.js +64 -0
- package/dist/email.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.js +170 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +98 -0
- package/dist/react.js +4 -0
- package/dist/react.js.map +1 -0
- package/dist/runner.d.ts +174 -0
- package/dist/runner.js +3 -0
- package/dist/runner.js.map +1 -0
- package/dist/server.d.ts +658 -0
- package/dist/server.js +995 -0
- package/dist/server.js.map +1 -0
- package/dist/supabase.d.ts +118 -0
- package/dist/supabase.js +3 -0
- package/dist/supabase.js.map +1 -0
- package/dist/types-BilguCPW.d.ts +332 -0
- package/dist/web.d.ts +79 -0
- package/dist/web.js +233 -0
- package/dist/web.js.map +1 -0
- package/functions/genesis-notifications/index.ts +60 -0
- package/functions/genesis-notifications/notifications.ts +18 -0
- package/migrations/0001_genesis_notifications.sql +2057 -0
- package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
- package/package.json +191 -3
- package/src/blocks.ts +42 -0
- package/src/core/index.ts +43 -0
- package/src/core/registry.ts +354 -0
- package/src/core/types.ts +90 -0
- package/src/core/validate.ts +73 -0
- package/src/email/index.tsx +142 -0
- package/src/index.ts +6 -0
- package/src/native/index.tsx +283 -0
- package/src/react/index.tsx +226 -0
- package/src/runner/index.ts +299 -0
- package/src/server/crypto.ts +44 -0
- package/src/server/dns.ts +106 -0
- package/src/server/email-render.ts +90 -0
- package/src/server/errors.ts +61 -0
- package/src/server/expo.ts +133 -0
- package/src/server/handler.ts +127 -0
- package/src/server/index.ts +57 -0
- package/src/server/notify.ts +100 -0
- package/src/server/rate.ts +47 -0
- package/src/server/resend.ts +143 -0
- package/src/server/runner.ts +524 -0
- package/src/server/secret-links.ts +53 -0
- package/src/server/store.ts +150 -0
- package/src/server/unsubscribe.ts +78 -0
- package/src/server/webhook.ts +55 -0
- package/src/supabase/index.ts +210 -0
- package/src/web/index.tsx +428 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@marktiderman/genesis-notifications/web`: the notifier's web screens,
|
|
3
|
+
* composed from genesis-ui only. Each reads the hooks from
|
|
4
|
+
* `@marktiderman/genesis-notifications/react` (inside `NotificationsProvider`).
|
|
5
|
+
*
|
|
6
|
+
* NotificationBell a bell with the unread count; opens the latest few
|
|
7
|
+
* NotificationFeed the whole feed, with mark read, archive and mark all read
|
|
8
|
+
* PreferencesMatrix categories × channels, a switch each (critical rows locked on)
|
|
9
|
+
* DeliveryLog admin: what was sent, to whom (masked), and what became of it
|
|
10
|
+
* SuppressionList admin: addresses not to email, and lifting one
|
|
11
|
+
* SendTestNotification admin: send yourself a test on every channel
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
16
|
+
import { Bell } from "lucide-react";
|
|
17
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
18
|
+
import {
|
|
19
|
+
Badge,
|
|
20
|
+
Button,
|
|
21
|
+
Card,
|
|
22
|
+
CardContent,
|
|
23
|
+
CardDescription,
|
|
24
|
+
CardHeader,
|
|
25
|
+
CardTitle,
|
|
26
|
+
EmptyState,
|
|
27
|
+
Popover,
|
|
28
|
+
PopoverContent,
|
|
29
|
+
PopoverTrigger,
|
|
30
|
+
ScrollArea,
|
|
31
|
+
Separator,
|
|
32
|
+
Switch,
|
|
33
|
+
Table,
|
|
34
|
+
TableBody,
|
|
35
|
+
TableCell,
|
|
36
|
+
TableHead,
|
|
37
|
+
TableHeader,
|
|
38
|
+
TableRow,
|
|
39
|
+
cn,
|
|
40
|
+
toast,
|
|
41
|
+
} from "@marktiderman/genesis-ui";
|
|
42
|
+
import type { Channel } from "../core/index";
|
|
43
|
+
import { useNotifications, useNotificationsContext, usePreferences, useUnreadCount, type FeedItem } from "../react/index";
|
|
44
|
+
|
|
45
|
+
export const CHANNEL_LABELS: Record<Channel, string> = { in_app: "In the app", push: "Push", email: "Email" };
|
|
46
|
+
|
|
47
|
+
function relative(iso: string, now = Date.now()): string {
|
|
48
|
+
const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
|
|
49
|
+
if (s < 60) return "just now";
|
|
50
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
51
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
52
|
+
return new Date(iso).toLocaleDateString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FeedRowProps {
|
|
56
|
+
item: FeedItem;
|
|
57
|
+
onOpen?: (item: FeedItem) => void;
|
|
58
|
+
testID?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One feed row: unread dot, title, body, age. */
|
|
62
|
+
export function NotificationRowView({ item, onOpen, testID }: FeedRowProps) {
|
|
63
|
+
return (
|
|
64
|
+
<Button
|
|
65
|
+
variant="ghost"
|
|
66
|
+
data-testid={testID ?? `notification-${item.id}`}
|
|
67
|
+
onClick={() => onOpen?.(item)}
|
|
68
|
+
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")}
|
|
69
|
+
>
|
|
70
|
+
<span aria-hidden className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", item.unread ? "bg-primary" : "bg-transparent")} />
|
|
71
|
+
<span className="min-w-0 flex-1">
|
|
72
|
+
<span className={cn("block truncate text-sm", item.unread ? "font-semibold" : "font-medium")}>{item.title}</span>
|
|
73
|
+
{item.body ? <span className="block truncate text-sm text-muted-foreground">{item.body}</span> : null}
|
|
74
|
+
</span>
|
|
75
|
+
<span className="shrink-0 text-xs text-muted-foreground">{relative(item.created_at)}</span>
|
|
76
|
+
{item.unread ? <span className="sr-only">Unread</span> : null}
|
|
77
|
+
</Button>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface NotificationBellProps {
|
|
82
|
+
/** Opens a notification's link (your router's navigate). Marked read first. */
|
|
83
|
+
onOpen?: (item: FeedItem) => void;
|
|
84
|
+
/** Where "See all" goes. */
|
|
85
|
+
onSeeAll?: () => void;
|
|
86
|
+
/** How many to show. Default 8. */
|
|
87
|
+
limit?: number;
|
|
88
|
+
className?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The bell. Opening it marks what it shows as seen; opening a row marks it read.
|
|
93
|
+
*
|
|
94
|
+
* @stability Beta
|
|
95
|
+
*/
|
|
96
|
+
export function NotificationBell({ onOpen, onSeeAll, limit = 8, className }: NotificationBellProps) {
|
|
97
|
+
const unread = useUnreadCount();
|
|
98
|
+
const { items, markRead, markAllRead, markAllSeen } = useNotifications({ limit });
|
|
99
|
+
const label = unread > 0 ? `Notifications, ${unread} unread` : "Notifications";
|
|
100
|
+
return (
|
|
101
|
+
<Popover onOpenChange={(open) => (open && items.some((i) => !i.seen_at) ? void markAllSeen() : undefined)}>
|
|
102
|
+
<PopoverTrigger asChild>
|
|
103
|
+
<Button variant="ghost" size="icon" aria-label={label} data-testid="notification-bell" className={cn("relative", className)}>
|
|
104
|
+
<Bell aria-hidden className="h-5 w-5" />
|
|
105
|
+
{unread > 0 ? (
|
|
106
|
+
<Badge data-testid="notification-bell-count" className="absolute -right-1 -top-1 h-5 min-w-5 justify-center px-1 text-xs">
|
|
107
|
+
{unread > 99 ? "99+" : unread}
|
|
108
|
+
</Badge>
|
|
109
|
+
) : null}
|
|
110
|
+
</Button>
|
|
111
|
+
</PopoverTrigger>
|
|
112
|
+
<PopoverContent align="end" className="w-80 p-0">
|
|
113
|
+
<div className="flex items-center justify-between px-3 py-2">
|
|
114
|
+
<span className="text-sm font-semibold">Notifications</span>
|
|
115
|
+
{unread > 0 ? (
|
|
116
|
+
<Button variant="link" size="sm" className="h-auto p-0" data-testid="notification-bell-mark-all" onClick={() => void markAllRead()}>
|
|
117
|
+
Mark all read
|
|
118
|
+
</Button>
|
|
119
|
+
) : null}
|
|
120
|
+
</div>
|
|
121
|
+
<Separator />
|
|
122
|
+
{items.length === 0 ? (
|
|
123
|
+
<EmptyState title="No notifications" description="When you get one, it'll show up here." className="py-8" />
|
|
124
|
+
) : (
|
|
125
|
+
<ScrollArea className="max-h-96">
|
|
126
|
+
<div className="p-1">
|
|
127
|
+
{items.map((item) => (
|
|
128
|
+
<NotificationRowView
|
|
129
|
+
key={item.id}
|
|
130
|
+
item={item}
|
|
131
|
+
onOpen={(i) => {
|
|
132
|
+
if (i.unread) void markRead(i.id);
|
|
133
|
+
onOpen?.(i);
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
</ScrollArea>
|
|
139
|
+
)}
|
|
140
|
+
{onSeeAll ? (
|
|
141
|
+
<>
|
|
142
|
+
<Separator />
|
|
143
|
+
<Button variant="ghost" className="w-full rounded-t-none" data-testid="notification-bell-see-all" onClick={onSeeAll}>
|
|
144
|
+
See all
|
|
145
|
+
</Button>
|
|
146
|
+
</>
|
|
147
|
+
) : null}
|
|
148
|
+
</PopoverContent>
|
|
149
|
+
</Popover>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface NotificationFeedProps {
|
|
154
|
+
onOpen?: (item: FeedItem) => void;
|
|
155
|
+
limit?: number;
|
|
156
|
+
title?: ReactNode;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The whole feed, in a card: mark read on open, archive, mark all read.
|
|
161
|
+
*
|
|
162
|
+
* @stability Beta
|
|
163
|
+
*/
|
|
164
|
+
export function NotificationFeed({ onOpen, limit = 100, title = "Notifications" }: NotificationFeedProps) {
|
|
165
|
+
const { items, isLoading, error, markRead, markAllRead, archive } = useNotifications({ limit });
|
|
166
|
+
const anyUnread = items.some((i) => i.unread);
|
|
167
|
+
return (
|
|
168
|
+
<Card data-testid="notification-feed">
|
|
169
|
+
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
170
|
+
<CardTitle>{title}</CardTitle>
|
|
171
|
+
{anyUnread ? (
|
|
172
|
+
<Button variant="outline" size="sm" data-testid="notification-feed-mark-all" onClick={() => void markAllRead()}>
|
|
173
|
+
Mark all read
|
|
174
|
+
</Button>
|
|
175
|
+
) : null}
|
|
176
|
+
</CardHeader>
|
|
177
|
+
<CardContent className="p-2">
|
|
178
|
+
{error ? (
|
|
179
|
+
<EmptyState title="Couldn't load notifications" description="Check your connection and try again." />
|
|
180
|
+
) : isLoading ? (
|
|
181
|
+
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
|
182
|
+
) : items.length === 0 ? (
|
|
183
|
+
<EmptyState title="No notifications" description="When you get one, it'll show up here." />
|
|
184
|
+
) : (
|
|
185
|
+
<ul className="divide-y divide-border">
|
|
186
|
+
{items.map((item) => (
|
|
187
|
+
<li key={item.id} className="flex items-center gap-2">
|
|
188
|
+
<NotificationRowView
|
|
189
|
+
item={item}
|
|
190
|
+
onOpen={(i) => {
|
|
191
|
+
if (i.unread) void markRead(i.id);
|
|
192
|
+
onOpen?.(i);
|
|
193
|
+
}}
|
|
194
|
+
/>
|
|
195
|
+
<Button variant="ghost" size="sm" aria-label={`Archive ${item.title}`} data-testid={`notification-archive-${item.id}`} onClick={() => void archive(item.id)}>
|
|
196
|
+
Archive
|
|
197
|
+
</Button>
|
|
198
|
+
</li>
|
|
199
|
+
))}
|
|
200
|
+
</ul>
|
|
201
|
+
)}
|
|
202
|
+
</CardContent>
|
|
203
|
+
</Card>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Categories × channels, a switch each. Critical categories are locked on.
|
|
209
|
+
* Saves each change at once and puts it back if the save fails.
|
|
210
|
+
*
|
|
211
|
+
* @stability Beta
|
|
212
|
+
*/
|
|
213
|
+
export function PreferencesMatrix({ channelLabels = CHANNEL_LABELS }: { channelLabels?: Partial<Record<Channel, string>> }) {
|
|
214
|
+
const { rows, status, set } = usePreferences();
|
|
215
|
+
const columns = (["in_app", "push", "email"] as const).filter((ch) => rows.some((r) => r.channels.includes(ch)));
|
|
216
|
+
if (status === "error") return <EmptyState title="Couldn't load your notification settings" description="Check your connection and try again." />;
|
|
217
|
+
return (
|
|
218
|
+
<Table data-testid="notification-preferences">
|
|
219
|
+
<TableHeader>
|
|
220
|
+
<TableRow>
|
|
221
|
+
<TableHead>Notify me about</TableHead>
|
|
222
|
+
{columns.map((ch) => (
|
|
223
|
+
<TableHead key={ch} className="text-center">
|
|
224
|
+
{channelLabels[ch] ?? CHANNEL_LABELS[ch]}
|
|
225
|
+
</TableHead>
|
|
226
|
+
))}
|
|
227
|
+
</TableRow>
|
|
228
|
+
</TableHeader>
|
|
229
|
+
<TableBody>
|
|
230
|
+
{rows.map((row) => (
|
|
231
|
+
<TableRow key={row.id}>
|
|
232
|
+
<TableCell>
|
|
233
|
+
<div className="font-medium">{row.label}</div>
|
|
234
|
+
{row.description ? <div className="text-sm text-muted-foreground">{row.description}</div> : null}
|
|
235
|
+
{row.critical ? <div className="text-xs text-muted-foreground">Always on</div> : null}
|
|
236
|
+
</TableCell>
|
|
237
|
+
{columns.map((ch) => (
|
|
238
|
+
<TableCell key={ch} className="text-center">
|
|
239
|
+
{row.channels.includes(ch) ? (
|
|
240
|
+
<Switch
|
|
241
|
+
aria-label={`${row.label}: ${channelLabels[ch] ?? CHANNEL_LABELS[ch]}`}
|
|
242
|
+
data-testid={`notification-preference-${row.id}-${ch}`}
|
|
243
|
+
checked={Boolean(row.values[ch])}
|
|
244
|
+
disabled={status !== "ready" || row.critical}
|
|
245
|
+
onCheckedChange={(on) =>
|
|
246
|
+
void set(row.id, ch, on).catch(() => toast.error("Couldn't save that change. Check your connection and try again."))
|
|
247
|
+
}
|
|
248
|
+
/>
|
|
249
|
+
) : (
|
|
250
|
+
<span className="text-muted-foreground" aria-label="Not available">
|
|
251
|
+
–
|
|
252
|
+
</span>
|
|
253
|
+
)}
|
|
254
|
+
</TableCell>
|
|
255
|
+
))}
|
|
256
|
+
</TableRow>
|
|
257
|
+
))}
|
|
258
|
+
</TableBody>
|
|
259
|
+
</Table>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const STATUS_VARIANT: Record<string, "success" | "warning" | "destructive" | "muted" | "info"> = {
|
|
264
|
+
delivered: "success",
|
|
265
|
+
sent: "success",
|
|
266
|
+
pending: "info",
|
|
267
|
+
sending: "info",
|
|
268
|
+
failed: "destructive",
|
|
269
|
+
skipped: "muted",
|
|
270
|
+
cancelled: "muted",
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Admin: the delivery log, newest first. Row level security shows an admin
|
|
275
|
+
* their tenants' rows only; content and full addresses are never here.
|
|
276
|
+
*
|
|
277
|
+
* @stability Beta
|
|
278
|
+
*/
|
|
279
|
+
export function DeliveryLog({ tenantId, limit = 100 }: { tenantId?: string | null; limit?: number }) {
|
|
280
|
+
const { client, userId } = useNotificationsContext();
|
|
281
|
+
const query = useQuery({ queryKey: ["genesis-notifications", userId, "log", tenantId ?? "all", limit], queryFn: () => client.deliveryLog({ tenantId, limit }), enabled: userId !== null });
|
|
282
|
+
const rows = query.data ?? [];
|
|
283
|
+
return (
|
|
284
|
+
<Card data-testid="notification-delivery-log">
|
|
285
|
+
<CardHeader>
|
|
286
|
+
<CardTitle>Delivery log</CardTitle>
|
|
287
|
+
<CardDescription>Every push and email the app sent, and what became of it.</CardDescription>
|
|
288
|
+
</CardHeader>
|
|
289
|
+
<CardContent>
|
|
290
|
+
{query.isError ? (
|
|
291
|
+
<EmptyState title="Couldn't load the log" description="You may not be an admin of this workspace." />
|
|
292
|
+
) : rows.length === 0 ? (
|
|
293
|
+
<EmptyState title="Nothing sent yet" />
|
|
294
|
+
) : (
|
|
295
|
+
<Table>
|
|
296
|
+
<TableHeader>
|
|
297
|
+
<TableRow>
|
|
298
|
+
<TableHead>When</TableHead>
|
|
299
|
+
<TableHead>Type</TableHead>
|
|
300
|
+
<TableHead>Channel</TableHead>
|
|
301
|
+
<TableHead>To</TableHead>
|
|
302
|
+
<TableHead>Status</TableHead>
|
|
303
|
+
<TableHead>Detail</TableHead>
|
|
304
|
+
</TableRow>
|
|
305
|
+
</TableHeader>
|
|
306
|
+
<TableBody>
|
|
307
|
+
{rows.map((r) => (
|
|
308
|
+
<TableRow key={r.id} data-testid={`notification-delivery-${r.id}`}>
|
|
309
|
+
<TableCell className="whitespace-nowrap text-sm">{new Date(r.created_at).toLocaleString()}</TableCell>
|
|
310
|
+
<TableCell className="font-mono text-xs">{r.type}</TableCell>
|
|
311
|
+
<TableCell>{r.channel === "email" ? "Email" : "Push"}</TableCell>
|
|
312
|
+
<TableCell className="text-sm">{r.recipient_masked ?? "—"}</TableCell>
|
|
313
|
+
<TableCell>
|
|
314
|
+
<Badge variant={STATUS_VARIANT[r.status] ?? "muted"}>{r.status}</Badge>
|
|
315
|
+
</TableCell>
|
|
316
|
+
<TableCell className="text-sm text-muted-foreground">
|
|
317
|
+
{r.skip_reason ?? r.error ?? (r.attempts > 1 ? `${r.attempts} attempts` : "")}
|
|
318
|
+
</TableCell>
|
|
319
|
+
</TableRow>
|
|
320
|
+
))}
|
|
321
|
+
</TableBody>
|
|
322
|
+
</Table>
|
|
323
|
+
)}
|
|
324
|
+
</CardContent>
|
|
325
|
+
</Card>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const REASONS: Record<string, string> = { bounce: "Bounced", complaint: "Marked as spam", unsubscribe: "Unsubscribed", manual: "Added by an admin" };
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Admin: addresses the app won't email (by a masked address; the list keeps
|
|
333
|
+
* only a hash), and lifting one.
|
|
334
|
+
*
|
|
335
|
+
* @stability Beta
|
|
336
|
+
*/
|
|
337
|
+
export function SuppressionList({ limit = 200 }: { limit?: number }) {
|
|
338
|
+
const { client, userId } = useNotificationsContext();
|
|
339
|
+
const queryClient = useQueryClient();
|
|
340
|
+
const key = ["genesis-notifications", userId, "suppressions", limit];
|
|
341
|
+
const query = useQuery({ queryKey: key, queryFn: () => client.suppressions({ limit }), enabled: userId !== null });
|
|
342
|
+
const rows = query.data ?? [];
|
|
343
|
+
return (
|
|
344
|
+
<Card data-testid="notification-suppressions">
|
|
345
|
+
<CardHeader>
|
|
346
|
+
<CardTitle>Suppressed addresses</CardTitle>
|
|
347
|
+
<CardDescription>Bounces and spam complaints stop email to an address; lift one once it's fixed.</CardDescription>
|
|
348
|
+
</CardHeader>
|
|
349
|
+
<CardContent>
|
|
350
|
+
{query.isError ? (
|
|
351
|
+
<EmptyState title="Couldn't load the list" description="Only the app's admins can see it." />
|
|
352
|
+
) : rows.length === 0 ? (
|
|
353
|
+
<EmptyState title="No suppressed addresses" />
|
|
354
|
+
) : (
|
|
355
|
+
<Table>
|
|
356
|
+
<TableHeader>
|
|
357
|
+
<TableRow>
|
|
358
|
+
<TableHead>Address</TableHead>
|
|
359
|
+
<TableHead>Why</TableHead>
|
|
360
|
+
<TableHead>Category</TableHead>
|
|
361
|
+
<TableHead>Since</TableHead>
|
|
362
|
+
<TableHead />
|
|
363
|
+
</TableRow>
|
|
364
|
+
</TableHeader>
|
|
365
|
+
<TableBody>
|
|
366
|
+
{rows.map((r) => (
|
|
367
|
+
<TableRow key={`${r.email_hash}:${r.category}`}>
|
|
368
|
+
<TableCell>{r.email_masked ?? `${r.email_hash.slice(0, 8)}…`}</TableCell>
|
|
369
|
+
<TableCell>{REASONS[r.reason] ?? r.reason}</TableCell>
|
|
370
|
+
<TableCell>{r.category === "*" ? "All" : r.category}</TableCell>
|
|
371
|
+
<TableCell className="whitespace-nowrap text-sm">{new Date(r.created_at).toLocaleDateString()}</TableCell>
|
|
372
|
+
<TableCell className="text-right">
|
|
373
|
+
<Button
|
|
374
|
+
variant="outline"
|
|
375
|
+
size="sm"
|
|
376
|
+
data-testid={`notification-suppression-lift-${r.email_hash.slice(0, 12)}`}
|
|
377
|
+
onClick={async () => {
|
|
378
|
+
try {
|
|
379
|
+
await client.removeSuppression(r.email_hash, r.category);
|
|
380
|
+
await queryClient.invalidateQueries({ queryKey: key });
|
|
381
|
+
} catch {
|
|
382
|
+
toast.error("Couldn't lift it. Try again.");
|
|
383
|
+
}
|
|
384
|
+
}}
|
|
385
|
+
>
|
|
386
|
+
Lift
|
|
387
|
+
</Button>
|
|
388
|
+
</TableCell>
|
|
389
|
+
</TableRow>
|
|
390
|
+
))}
|
|
391
|
+
</TableBody>
|
|
392
|
+
</Table>
|
|
393
|
+
)}
|
|
394
|
+
</CardContent>
|
|
395
|
+
</Card>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Admin: send yourself a test notification (in the app, push and email).
|
|
401
|
+
*
|
|
402
|
+
* @stability Beta
|
|
403
|
+
*/
|
|
404
|
+
export function SendTestNotification({ tenantId = null, label = "Send a test" }: { tenantId?: string | null; label?: string }) {
|
|
405
|
+
const { client } = useNotificationsContext();
|
|
406
|
+
const [busy, setBusy] = useState(false);
|
|
407
|
+
const [mounted, setMounted] = useState(true);
|
|
408
|
+
useEffect(() => () => setMounted(false), []);
|
|
409
|
+
return (
|
|
410
|
+
<Button
|
|
411
|
+
data-testid="notification-send-test"
|
|
412
|
+
disabled={busy}
|
|
413
|
+
onClick={async () => {
|
|
414
|
+
setBusy(true);
|
|
415
|
+
try {
|
|
416
|
+
await client.sendTest(tenantId);
|
|
417
|
+
toast.success("Test sent. It reaches you on each channel you have on.");
|
|
418
|
+
} catch {
|
|
419
|
+
toast.error("Couldn't send the test. Only admins can.");
|
|
420
|
+
} finally {
|
|
421
|
+
if (mounted) setBusy(false);
|
|
422
|
+
}
|
|
423
|
+
}}
|
|
424
|
+
>
|
|
425
|
+
{label}
|
|
426
|
+
</Button>
|
|
427
|
+
);
|
|
428
|
+
}
|