feat: real-time alerts via WebSocket push notifications

- Add ws WebSocket server (port 3001) with JWT auth and user-socket mapping
- Add WebSocket client with exponential backoff reconnection and heartbeat
- Add useRealtimeAlerts hook with toast notifications and unread badge
- Add alert.publisher service (WS → push → email fallback)
- Integrate publisher into DarkWatch, VoicePrint, HomeTitle, SpamShield, RemoveBrokers
- Update Navbar with connection status indicator and unread count
- Add comprehensive tests (14 passing) for server, client, and publisher
This commit is contained in:
2026-05-25 17:58:47 -04:00
parent 3a8e329f02
commit c02457c66a
16 changed files with 1197 additions and 26 deletions

View File

@@ -1,10 +1,11 @@
import { createSignal, onMount, onCleanup, Show, Suspense } from "solid-js";
import { createSignal, onMount, onCleanup, Show } from "solid-js";
import { A } from "@solidjs/router";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui";
import { Typewriter } from "~/components/ui/Typewriter";
import { useTheme } from "~/lib/theme";
import { SignedIn, SignedOut, UserButton } from "clerk-solidjs";
import { useRealtimeAlerts } from "~/hooks/useRealtimeAlerts";
function ShieldLogo() {
return (
@@ -125,6 +126,51 @@ const navLinks = [
{ label: "Dashboard", href: "/dashboard" },
];
function RealtimeIndicator() {
const { connectionStatus, unreadCount, clearUnread } = useRealtimeAlerts();
return (
<div class="flex items-center gap-2">
<Show when={unreadCount() > 0}>
<button
type="button"
onClick={clearUnread}
class="relative flex items-center justify-center w-6 h-6 rounded-full bg-[var(--color-error)] text-white text-[10px] font-bold leading-none transition-transform hover:scale-110"
aria-label={`${unreadCount()} unread alerts`}
>
{unreadCount() > 99 ? "99+" : unreadCount()}
</button>
</Show>
<Show
when={connectionStatus() === "connected"}
fallback={
connectionStatus() === "reconnecting" || connectionStatus() === "connecting" ? (
<div class="flex items-center gap-1 text-[10px] text-[var(--color-warning)]" aria-label="Reconnecting">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-warning)] opacity-75" />
<span class="relative inline-flex rounded-full h-2 w-2 bg-[var(--color-warning)]" />
</span>
<span class="hidden sm:inline">Reconnecting</span>
</div>
) : (
<div class="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]" aria-label="Offline">
<span class="inline-flex rounded-full h-2 w-2 bg-[var(--color-text-muted)]" />
<span class="hidden sm:inline">Offline</span>
</div>
)
}
>
<div class="flex items-center gap-1" aria-label="Connected">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-success)] opacity-75" />
<span class="relative inline-flex rounded-full h-2 w-2 bg-[var(--color-success)]" />
</span>
</div>
</Show>
</div>
);
}
export default function Navbar() {
const [mobileOpen, setMobileOpen] = createSignal(false);
const [scrolled, setScrolled] = createSignal(false);
@@ -169,6 +215,7 @@ export default function Navbar() {
<ThemeToggle />
<SignedIn>
<UserButton showName />
<RealtimeIndicator />
<Button variant="secondary" size="sm">
<A href="/dashboard">Dashboard</A>
</Button>