From d32423ab4fea97dd75a4601e480418947f5631b6 Mon Sep 17 00:00:00 2001 From: Adarsh Bharadwaj Date: Fri, 8 Aug 2025 18:58:11 +0530 Subject: [PATCH] Add notification system with SSE Events --- README.md | 12 +- package-lock.json | 224 +++++++++++++++++- package.json | 3 + .../(protected)/sse/notifications/error.tsx | 8 + .../(protected)/sse/notifications/page.tsx | 17 ++ src/app/api/sse/route.ts | 31 +++ src/config/routes.ts | 6 + .../notifications/components/ClientList.tsx | 53 +++++ .../components/Notifications.tsx | 136 +++++++++++ .../notifications/hooks/useSSEConnection.ts | 56 +++++ src/features/notifications/index.ts | 5 + src/features/notifications/package.json | 6 + .../trpc/handlers/notificationHandler.ts | 44 ++++ src/features/notifications/trpc/router.ts | 11 + src/features/notifications/types/index.ts | 16 ++ .../shared/components/ui/checkbox.tsx | 31 +++ src/features/shared/components/ui/input.tsx | 20 ++ src/features/shared/components/ui/label.tsx | 23 ++ src/lib/sse/index.ts | 153 ++++++++++++ src/lib/sse/types.ts | 19 ++ src/lib/trpc/root.ts | 2 + 21 files changed, 870 insertions(+), 6 deletions(-) create mode 100644 src/app/(protected)/sse/notifications/error.tsx create mode 100644 src/app/(protected)/sse/notifications/page.tsx create mode 100644 src/app/api/sse/route.ts create mode 100644 src/features/notifications/components/ClientList.tsx create mode 100644 src/features/notifications/components/Notifications.tsx create mode 100644 src/features/notifications/hooks/useSSEConnection.ts create mode 100644 src/features/notifications/index.ts create mode 100644 src/features/notifications/package.json create mode 100644 src/features/notifications/trpc/handlers/notificationHandler.ts create mode 100644 src/features/notifications/trpc/router.ts create mode 100644 src/features/notifications/types/index.ts create mode 100644 src/features/shared/components/ui/checkbox.tsx create mode 100644 src/features/shared/components/ui/input.tsx create mode 100644 src/features/shared/components/ui/label.tsx create mode 100644 src/lib/sse/index.ts create mode 100644 src/lib/sse/types.ts diff --git a/README.md b/README.md index 8c838ae..fcbbdcf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Nomey Web App + This is the official repository for the Nomey web app, built on the T3 Stack with custom extensions. ## Tech Stack @@ -12,7 +13,7 @@ This is the official repository for the Nomey web app, built on the T3 Stack wit - [tolgee](https://tolgee.io/) - Translation Management - [Meilisearch](https://www.meilisearch.com/) - Full-text search - [Upstash](https://upstash.com/) Next compatible redis -- [Qstash](https://upstash.com/docs/qstash) Next compatible queue handling +- [Qstash](https://upstash.com/docs/qstash) Next compatible queue handling - [Vitest](https://vitest.dev/) - Testing Framework ## Testing @@ -42,6 +43,7 @@ npm run test ## Local Development ### Clone and Install + ```bash git clone git@github.com:nomeyy/nomey-next.git cd nomey-next @@ -62,7 +64,7 @@ npm run dev ## Learn More - - [Nomey Documentation (WIP)](https://nomey.mintlify.app/) - - [Next Documentation](https://nextjs.org/docs) - - [T3 Stack Documentation](https://create.t3.gg/en/usage/first-steps) - - [Mux Documentation](https://www.mux.com/docs) +- [Nomey Documentation (WIP)](https://nomey.mintlify.app/) +- [Next Documentation](https://nextjs.org/docs) +- [T3 Stack Documentation](https://create.t3.gg/en/usage/first-steps) +- [Mux Documentation](https://www.mux.com/docs) diff --git a/package-lock.json b/package-lock.json index 46a5af0..b8bf70e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@mux/mux-player-react": "^3.4.0", "@mux/mux-uploader-react": "^1.2.0", "@prisma/client": "^6.10.0", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", "@react-email/components": "^0.0.42", "@t3-oss/env-nextjs": "^0.12.0", @@ -39,6 +41,7 @@ "server-only": "^0.0.1", "superjson": "^2.2.1", "tailwind-merge": "^3.3.1", + "uuid": "^11.1.0", "zod": "^3.24.2" }, "devDependencies": { @@ -2162,6 +2165,42 @@ "@prisma/debug": "6.10.1" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -2177,6 +2216,91 @@ } } }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -2195,6 +2319,91 @@ } } }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@react-email/body": { "version": "0.0.11", "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.11.tgz", @@ -3439,7 +3648,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -10691,6 +10900,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", diff --git a/package.json b/package.json index 0c65b9d..c950fdd 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "@mux/mux-player-react": "^3.4.0", "@mux/mux-uploader-react": "^1.2.0", "@prisma/client": "^6.10.0", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", "@react-email/components": "^0.0.42", "@t3-oss/env-nextjs": "^0.12.0", @@ -60,6 +62,7 @@ "server-only": "^0.0.1", "superjson": "^2.2.1", "tailwind-merge": "^3.3.1", + "uuid": "^11.1.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/src/app/(protected)/sse/notifications/error.tsx b/src/app/(protected)/sse/notifications/error.tsx new file mode 100644 index 0000000..0d72a6e --- /dev/null +++ b/src/app/(protected)/sse/notifications/error.tsx @@ -0,0 +1,8 @@ +"use client"; + +import ErrorBoundary from "@/shared/components/ErrorBoundary"; + +const Error = ({ error }: { error: Error & { digest?: string } }) => + ErrorBoundary({ error }); + +export default Error; diff --git a/src/app/(protected)/sse/notifications/page.tsx b/src/app/(protected)/sse/notifications/page.tsx new file mode 100644 index 0000000..0903318 --- /dev/null +++ b/src/app/(protected)/sse/notifications/page.tsx @@ -0,0 +1,17 @@ +import { paths } from "@/config/routes"; +import { getSession } from "@/features/auth"; +import { Notifications } from "@/features/notifications"; +import { redirect, RedirectType } from "next/navigation"; +import React from "react"; + +const SSENotificationsPage = async () => { + const session = await getSession(); + + if (!session?.user) { + redirect(paths.landingPage, RedirectType.replace); + } + + return ; +}; + +export default SSENotificationsPage; diff --git a/src/app/api/sse/route.ts b/src/app/api/sse/route.ts new file mode 100644 index 0000000..35cac13 --- /dev/null +++ b/src/app/api/sse/route.ts @@ -0,0 +1,31 @@ +import { getSession } from "@/features/auth"; +import { SSEManager } from "@/lib/sse"; + +export async function GET() { + const session = await getSession(); + const user = session?.user; + + if (!user) { + return new Response("Unauthorized client", { + status: 401, + headers: { "Content-Type": "text/plain" }, + }); + } + + const stream = new ReadableStream({ + start(controller) { + SSEManager.getInstance().addClient(controller, user); + }, + cancel() { + SSEManager.getInstance().removeClient(user.id); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/src/config/routes.ts b/src/config/routes.ts index 8908c75..55f0bd0 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -31,6 +31,7 @@ export const paths = { landingPage: "/", homePage: "/home", reelsUploadPage: "/reels/upload", + sseNotificationPage: "/sse/notifications", } as const; // ⚠️ DEFINE METADATA FOR NEW ROUTES HERE ⚠️ @@ -51,4 +52,9 @@ export const routes: Record = { path: paths.reelsUploadPage, accessType: "protected", }, + sseNotificationPage: { + name: "SSE Notification Page", + path: paths.sseNotificationPage, + accessType: "protected", + }, }; diff --git a/src/features/notifications/components/ClientList.tsx b/src/features/notifications/components/ClientList.tsx new file mode 100644 index 0000000..25b26c6 --- /dev/null +++ b/src/features/notifications/components/ClientList.tsx @@ -0,0 +1,53 @@ +import { Button } from "@/shared/components/ui/button"; +import type { Client } from "../hooks/useSSEConnection"; + +function ClientList({ + clients, + currentUserId, + currentUserName, + onSend, + disabled, +}: { + clients: Client[]; + currentUserId?: string; + currentUserName?: string; + onSend: (id: string) => void; + disabled: boolean; +}) { + return ( +
+

+ Connected Users ({clients.length}) +

+ {clients.length === 0 ? ( +

No users connected...

+ ) : ( +
    + {clients.map((client) => ( +
  • + + {client.id === currentUserId + ? `${currentUserName} (You)` + : client.name} + + {client.id !== currentUserId && ( + + )} +
  • + ))} +
+ )} +
+ ); +} + +export default ClientList; diff --git a/src/features/notifications/components/Notifications.tsx b/src/features/notifications/components/Notifications.tsx new file mode 100644 index 0000000..6b8b2b0 --- /dev/null +++ b/src/features/notifications/components/Notifications.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { Button } from "@/shared/components/ui/button"; +import { api } from "@/trpc/react"; +import type { Session } from "next-auth"; +import { useState, useCallback, useRef } from "react"; +import { useSSEConnection } from "../hooks/useSSEConnection"; +import ClientList from "./ClientList"; +import { Input } from "@/shared/components/ui/input"; +import { Checkbox } from "@/shared/components/ui/checkbox"; +import { Label } from "@/shared/components/ui/label"; + +interface NotificationDisplayProps { + user: Session["user"]; +} + +export default function NotificationDisplay({ + user, +}: NotificationDisplayProps) { + const [eventName, setEventName] = useState(""); + const [payload, setPayload] = useState(""); + const [broadcast, setBroadcast] = useState(false); + const sendNotification = api.notifications.notification.useMutation(); + + const { connectionStatus, connectedClients, messages } = useSSEConnection(); + + const messageListRef = useRef(null); + + const handleSendNotification = useCallback( + (targetClientId?: string) => { + if (!eventName.trim()) return alert("Event name cannot be empty"); + + sendNotification.mutate({ + clientId: broadcast ? undefined : targetClientId, + eventType: eventName, + payload: { + message: `${broadcast ? "Broadcast" : "Notification"} from ${user?.name} : ${payload}`, + }, + }); + + setEventName(""); + setPayload(""); + setBroadcast(false); + }, + [eventName, payload, broadcast, sendNotification, user?.name], + ); + + return ( +
+
+

+ Real-Time Notifications +

+

+ Status:{" "} + + {connectionStatus} + +

+ + {/* Notification Form */} +
+ + setEventName(e.target.value)} + placeholder="Enter event name" + /> + + + setPayload(e.target.value)} + placeholder="Enter payload message" + /> + + + { + if (typeof checked === "boolean") { + setBroadcast(checked); + } else { + setBroadcast(false); + } + }} + /> + + {!broadcast ? ( + + ) : ( + + )} +
+ + {/* Messages */} +
+

Messages

+ {messages.length === 0 ? ( +

No messages yet...

+ ) : ( +
    + {messages.map((msg, index) => ( +
  • + {msg.type} + {msg.message &&

    {msg.message}

    } +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/features/notifications/hooks/useSSEConnection.ts b/src/features/notifications/hooks/useSSEConnection.ts new file mode 100644 index 0000000..90502ea --- /dev/null +++ b/src/features/notifications/hooks/useSSEConnection.ts @@ -0,0 +1,56 @@ +import { useState, useEffect } from "react"; + +export type Client = { + id: string; + name?: string | null; + image?: string | null; +}; + +export type Message = { + type: string; + clientId?: string; + clients?: Client[]; + message?: string; +}; + +export function useSSEConnection() { + const [messages, setMessages] = useState([]); + const [connectedClients, setConnectedClients] = useState([]); + const [connectionStatus, setConnectionStatus] = useState("Connecting..."); + + useEffect(() => { + let eventSource: EventSource; + let reconnectTimeout: NodeJS.Timeout | null = null; + + const connect = () => { + eventSource = new EventSource("/api/sse"); + + eventSource.onopen = () => setConnectionStatus("Connected"); + + eventSource.onmessage = (event: MessageEvent) => { + const data = JSON.parse(event.data) as Message; + if (data.type === "connections") { + setConnectedClients(data.clients ?? []); + } else { + setMessages((prev) => [...prev, data]); + } + }; + + eventSource.onerror = () => { + setConnectionStatus("Disconnected"); + eventSource.close(); + reconnectTimeout = setTimeout(connect, 5000); + }; + }; + + connect(); + + return () => { + eventSource.close(); + if (reconnectTimeout) clearTimeout(reconnectTimeout); + setConnectionStatus("Disconnected"); + }; + }, []); + + return { messages, connectedClients, connectionStatus }; +} diff --git a/src/features/notifications/index.ts b/src/features/notifications/index.ts new file mode 100644 index 0000000..0441f3a --- /dev/null +++ b/src/features/notifications/index.ts @@ -0,0 +1,5 @@ +import Notifications from "./components/Notifications"; + +export { Notifications }; +export { notificationRouter } from "./trpc/router"; +export * from "./types"; diff --git a/src/features/notifications/package.json b/src/features/notifications/package.json new file mode 100644 index 0000000..c6cb2e0 --- /dev/null +++ b/src/features/notifications/package.json @@ -0,0 +1,6 @@ +{ + "name": "@features/notifications", + "private": true, + "main": "./index.ts", + "types": "./index.ts" +} diff --git a/src/features/notifications/trpc/handlers/notificationHandler.ts b/src/features/notifications/trpc/handlers/notificationHandler.ts new file mode 100644 index 0000000..fb0a553 --- /dev/null +++ b/src/features/notifications/trpc/handlers/notificationHandler.ts @@ -0,0 +1,44 @@ +import { createServiceContext } from "@/utils/service-utils"; +import { TRPCError } from "@trpc/server"; +import type { SseNotificationInput } from "../../types"; +import { SSEManager } from "@/lib/sse"; + +const { log } = createServiceContext("notifications-service"); + +export function sseNotificationHandler(input: SseNotificationInput) { + try { + log.info("Sending SSE notification", { + clientId: input.clientId, + eventType: input.eventType, + }); + + const sseManagerInstance = SSEManager.getInstance(); + + if (input.clientId) { + sseManagerInstance.sendEvent(input.clientId, { + type: input.eventType, + clientId: input.clientId, + ...input.payload, + }); + log.info(`Sent event to client: ${input.clientId}`, { + eventType: input.eventType, + }); + } else { + sseManagerInstance.broadcast({ + type: input.eventType, + ...input.payload, + }); + log.info("Broadcast event to all clients", { + eventType: input.eventType, + }); + } + + return { success: true }; + } catch (error) { + log.error("Failed to send SSE notification", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to send SSE notification", + }); + } +} diff --git a/src/features/notifications/trpc/router.ts b/src/features/notifications/trpc/router.ts new file mode 100644 index 0000000..4d94fb2 --- /dev/null +++ b/src/features/notifications/trpc/router.ts @@ -0,0 +1,11 @@ +import { createTRPCRouter, publicProcedure } from "@/lib/trpc"; +import { sseNotificationHandler } from "./handlers/notificationHandler"; +import { notificationSchema } from "../types"; + +export const notificationRouter = createTRPCRouter({ + notification: publicProcedure + .input(notificationSchema) + .mutation(({ input }) => { + return sseNotificationHandler(input); + }), +}); diff --git a/src/features/notifications/types/index.ts b/src/features/notifications/types/index.ts new file mode 100644 index 0000000..ec70ec8 --- /dev/null +++ b/src/features/notifications/types/index.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export enum EventType { + TEST = "test", + CONNECT = "connect", + DISCONNECT = "disconnect", + PING = "ping", +} + +export const notificationSchema = z.object({ + clientId: z.string().optional(), + eventType: z.union([z.nativeEnum(EventType), z.string()]), + payload: z.any(), // Type-safe alternative to z.any() +}); + +export type SseNotificationInput = z.infer; diff --git a/src/features/shared/components/ui/checkbox.tsx b/src/features/shared/components/ui/checkbox.tsx new file mode 100644 index 0000000..4643694 --- /dev/null +++ b/src/features/shared/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@/shared/utils"; + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/src/features/shared/components/ui/input.tsx b/src/features/shared/components/ui/input.tsx new file mode 100644 index 0000000..a0d733c --- /dev/null +++ b/src/features/shared/components/ui/input.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/shared/utils"; +import * as React from "react"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/src/features/shared/components/ui/label.tsx b/src/features/shared/components/ui/label.tsx new file mode 100644 index 0000000..f87248e --- /dev/null +++ b/src/features/shared/components/ui/label.tsx @@ -0,0 +1,23 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cn } from "@/shared/utils"; + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/src/lib/sse/index.ts b/src/lib/sse/index.ts new file mode 100644 index 0000000..d086607 --- /dev/null +++ b/src/lib/sse/index.ts @@ -0,0 +1,153 @@ +import type { Client, User } from "./types"; + +export class SSEManager { + private static instance: SSEManager; + + // Stores connected clients in memory: Map + private clients = new Map(); + + // Heartbeat timer reference (null if stopped) + private heartbeatInterval: NodeJS.Timeout | null = null; + + // How often heartbeat events are sent to keep connections alive + private readonly HEARTBEAT_INTERVAL_MS = 30000; // 30 seconds + + /** + * Returns the single instance of SSEManager (Singleton pattern) + */ + static getInstance(): SSEManager { + if (!SSEManager.instance) { + SSEManager.instance = new SSEManager(); + } + return SSEManager.instance; + } + + // Private constructor ensures no external instantiation + private constructor() { + this.startHeartbeat(); + } + + /** + * Starts the heartbeat ping loop (only one allowed at a time) + * Sends a `ping` message to all clients every HEARTBEAT_INTERVAL_MS + */ + private startHeartbeat() { + if (this.heartbeatInterval) return; // Already running + + this.heartbeatInterval = setInterval(() => { + this.broadcast({ + type: "ping", + message: `Heartbeat at ${new Date().toISOString()}`, + }); + }, this.HEARTBEAT_INTERVAL_MS); + } + + /** + * Stops the heartbeat loop (used when no clients are connected) + */ + private stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + + /** + * Adds a new SSE client to the list + * @param controller The stream controller used to send SSE events + * @param user The authenticated user's info + */ + addClient(controller: ReadableStreamDefaultController, user: User) { + // Store the client in memory keyed by user ID + this.clients.set(user.id, { controller, ...user }); + + // Notify all clients about the new connection + this.broadcast({ + type: "connect", + message: `${user?.name} connected`, + clientId: user?.id, + }); + + // Update connection count for all clients + this.broadcastCount(); + } + + /** + * Removes a client from the list (on disconnect) + * @param id The user ID of the disconnecting client + */ + removeClient(id: string) { + const client = this.clients.get(id); + + // Remove from memory + this.clients.delete(id); + + // Notify others about the disconnection + this.broadcast({ + type: "disconnect", + message: `${client?.name} disconnected`, + clientId: id, + }); + + // Update connection count for all clients + this.broadcastCount(); + + // If no clients remain, stop heartbeat + if (this.clients.size === 0) { + this.stopHeartbeat(); + } + } + + /** + * Sends an event to a specific client + * @param clientId The target user's ID + * @param message The message object to send (will be JSON stringified) + */ + sendEvent(clientId: string, message: T) { + const encoder = new TextEncoder(); + const payload = `data: ${JSON.stringify(message)}\n\n`; + const client = this.clients.get(clientId); + + if (client) { + try { + client.controller.enqueue(encoder.encode(payload)); + } catch (error) { + console.error(`Error sending to client ${clientId}:`, error); + this.removeClient(clientId); // Remove dead connection + } + } + } + + /** + * Sends an event to all connected clients + * @param message The message object to send (will be JSON stringified) + */ + broadcast(message: T) { + const encoder = new TextEncoder(); + const payload = `data: ${JSON.stringify(message)}\n\n`; + + for (const client of this.clients.values()) { + try { + client.controller.enqueue(encoder.encode(payload)); + } catch (error) { + console.error(`Error broadcasting to client ${client.id}:`, error); + this.removeClient(client.id); // Remove dead connection + } + } + + // If clients exist but heartbeat isn't running, restart it + if (this.clients.size > 0 && !this.heartbeatInterval) { + this.startHeartbeat(); + } + } + + /** + * Sends the current list of connected clients (for UI updates) + */ + private broadcastCount() { + const clients = Array.from(this.clients.values()).map( + ({ image, id, name }) => ({ id, image, name }), // Only public-facing data + ); + this.broadcast({ type: "connections", clients }); + } +} diff --git a/src/lib/sse/types.ts b/src/lib/sse/types.ts new file mode 100644 index 0000000..e91c443 --- /dev/null +++ b/src/lib/sse/types.ts @@ -0,0 +1,19 @@ +import type { Session } from "next-auth"; + +export interface SSEEvent { + type: string; + payload: unknown; +} + +export interface SSEClient { + response: Response; + stream: ReadableStream; +} + +// Extract the User type from NextAuth Session +export type User = Session["user"]; + +// Represents an SSE client with its stream controller + user info +export type Client = { + controller: ReadableStreamDefaultController; +} & User; diff --git a/src/lib/trpc/root.ts b/src/lib/trpc/root.ts index 129dcab..e822939 100644 --- a/src/lib/trpc/root.ts +++ b/src/lib/trpc/root.ts @@ -1,5 +1,6 @@ import { createCallerFactory, createTRPCRouter } from "@/lib/trpc"; import { searchRouter } from "@/features/search"; +import { notificationRouter } from "@/features/notifications"; /** * This is the primary router for your server. @@ -8,6 +9,7 @@ import { searchRouter } from "@/features/search"; */ export const appRouter = createTRPCRouter({ search: searchRouter, + notifications: notificationRouter, }); // export type definition of API