Files
silo-server/web/src/hooks/queries/notificationWebhooks.ts
T
QuickandClaude Fable 5 b091f0c6c1 feat(notifications): in-app inbox, realtime, webhooks, web push + shared SMTP core
Implements the notification system foundation and all v1 delivery channels
that need no external infrastructure (specs 00/01/04/05 in
docs/superpowers/plans/notifications/):

Foundation (spec 01):
- episode_availability seeding + per-library seed markers: "newly available"
  means newly released to this server, so back-catalog imports and first
  scans never flood (verified on dev: 1.13M episodes seeded silently)
- release_events -> profile_series_interest fanout worker with settling
  delay, per-series burst caps, FOR UPDATE SKIP LOCKED multi-node claims,
  and a guarded last-notified cursor
- interest index maintained via a userstore provider decorator so every
  favorites/watchlist/progress mutation path (REST, jellycompat, imports,
  playback) feeds it; progress writes only recompute on state transitions
- durable per-profile inbox + read state, forward-sync cursor API,
  websocket channel with short-lived single-use handshake tickets
- web UI: sidebar badge, inbox page, toasts, per-profile preferences
- startup/daily tasks: availability seeding, interest rebuild, retention

Outbound webhooks (spec 04):
- Discord embeds (text-only per the v1 privacy contract) and generic
  JSON signed Stripe-style with per-webhook secrets
- HTTPS-only + private-destination guard enforced at registration and at
  connect time (DNS-rebinding mitigation); URLs/secrets encrypted at rest
- durable per-target outbox enqueued in the fanout transaction, lease-based
  claims, 24h exponential retry, 3x-consecutive-4xx auto-disable with an
  in-app notice (loop-guarded)

Web push (spec 05):
- VAPID keypair self-provisioned at startup (single atomic JSON setting,
  private half encrypted at rest) — no third-party accounts needed
- payloads E2E-encrypted (RFC 8291); 404/410 treated as unsubscribe
- service worker + subscribe flow in Settings -> Notifications

Shared SMTP core (internal/mail):
- feature-agnostic mail.Sender over live email.* settings, STARTTLS or
  implicit TLS, encrypted password, admin Email settings page with
  synchronous test send; no consumer yet by design (digest is v1.5)

APNs/FCM (specs 02/03) are deferred to v2; the capability endpoint reports
them unavailable so clients render truthfully.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:46 -04:00

122 lines
3.6 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
import type {
NotificationCapability,
NotificationWebhook,
NotificationWebhookInput,
NotificationWebhookTestResult,
WebPushSubscriptionView,
} from "@/api/types";
import { notificationKeys } from "./keys";
import { toast } from "sonner";
export function useNotificationCapability() {
return useQuery({
queryKey: notificationKeys.capability(),
queryFn: () => api<NotificationCapability>("/notifications/capability"),
staleTime: 5 * 60_000,
});
}
export function useNotificationWebhooks(enabled = true) {
return useQuery({
queryKey: notificationKeys.webhooks(),
queryFn: () =>
api<{ webhooks: NotificationWebhook[] }>("/notifications/webhooks").then(
(d) => d.webhooks ?? [],
),
enabled,
});
}
export function useCreateNotificationWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: NotificationWebhookInput) =>
api<NotificationWebhook>("/notifications/webhooks", {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.webhooks() });
},
});
}
export function useUpdateNotificationWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...input }: NotificationWebhookInput & { id: string }) =>
api<NotificationWebhook>(`/notifications/webhooks/${id}`, {
method: "PUT",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.webhooks() });
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to update webhook");
},
});
}
export function useDeleteNotificationWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api(`/notifications/webhooks/${id}`, { method: "DELETE" }),
onSuccess: () => {
toast.success("Webhook deleted");
void queryClient.invalidateQueries({ queryKey: notificationKeys.webhooks() });
},
onError: () => {
toast.error("Failed to delete webhook");
},
});
}
export function useTestNotificationWebhook() {
return useMutation({
mutationFn: (id: string) =>
api<NotificationWebhookTestResult>(`/notifications/webhooks/${id}/test`, {
method: "POST",
}),
});
}
export function useRotateNotificationWebhookSecret() {
return useMutation({
mutationFn: (id: string) =>
api<{ signing_secret: string }>(`/notifications/webhooks/${id}/rotate-secret`, {
method: "POST",
}),
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to rotate signing secret");
},
});
}
export function useWebPushSubscriptions(enabled = true) {
return useQuery({
queryKey: notificationKeys.webPushSubscriptions(),
queryFn: () =>
api<{ subscriptions: WebPushSubscriptionView[] }>(
"/notifications/web-push/subscriptions",
).then((d) => d.subscriptions ?? []),
enabled,
});
}
export function useDeleteWebPushSubscription() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
api(`/notifications/web-push/subscriptions/${id}`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.webPushSubscriptions() });
},
onError: () => {
toast.error("Failed to remove push subscription");
},
});
}