From 485e26bbda542948dea289845c3976125d6f4f2d Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 21 Aug 2026 00:25:20 -0400 Subject: [PATCH] feat(realtime): publish user-scoped update events --- src/app/api/realtime/route.ts | 5 +++ src/collections/users/Profiles.ts | 11 +++++ .../notifications/NotificationsBell.tsx | 6 ++- .../frontend/realtime/GameTickRealtime.tsx | 41 +++++++++++++++++-- src/hooks/useGameTick.ts | 8 ++++ src/lib/notifications/index.ts | 3 ++ src/lib/realtime/bus.ts | 16 ++++++++ src/utils/event-log/emit.ts | 7 ++++ 8 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/app/api/realtime/route.ts b/src/app/api/realtime/route.ts index 2287fcc..2304899 100644 --- a/src/app/api/realtime/route.ts +++ b/src/app/api/realtime/route.ts @@ -1,4 +1,6 @@ import { NextRequest } from "next/server"; +import config from "@payload-config"; +import { getPayload } from "payload"; import { type SSEEventClient, subscribe, unsubscribe } from "@/lib/realtime/bus"; import { getPresence } from "@/lib/realtime/presence"; @@ -6,6 +8,8 @@ export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function GET(req: NextRequest) { + const payload = await getPayload({ config: await config }); + const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false }); const encoder = new TextEncoder(); const channel = req.nextUrl.searchParams.get("channel"); @@ -23,6 +27,7 @@ export async function GET(req: NextRequest) { start(controller) { client = { send: (data) => controller.enqueue(encoder.encode(data)), + userId: user ? Number(user.id) : undefined, events: channel ? new Set(["presence"]) : undefined, }; subscribe(client); diff --git a/src/collections/users/Profiles.ts b/src/collections/users/Profiles.ts index 80bf1a7..cbe9975 100644 --- a/src/collections/users/Profiles.ts +++ b/src/collections/users/Profiles.ts @@ -1,5 +1,6 @@ import { CollectionConfig } from "payload"; import { Award } from "@/payload-types"; +import { broadcastToUser } from "@/lib/realtime/bus"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission"; export const Profiles: CollectionConfig = { @@ -23,6 +24,16 @@ export const Profiles: CollectionConfig = { return await hasPermission(req.payload, req.user, "profiles:delete"); }, }, + hooks: { + afterChange: [ + async ({ doc }) => { + const userId = typeof doc.user === "object" ? doc.user?.id : doc.user; + if (typeof userId === "number") { + broadcastToUser("profile-update", userId, { profileId: doc.id }); + } + }, + ], + }, fields: [ { name: "user", diff --git a/src/components/frontend/notifications/NotificationsBell.tsx b/src/components/frontend/notifications/NotificationsBell.tsx index f3330f4..4865d35 100644 --- a/src/components/frontend/notifications/NotificationsBell.tsx +++ b/src/components/frontend/notifications/NotificationsBell.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { notificationLabel } from "@/lib/notifications"; +import { NOTIFICATION_EVENT } from "@/hooks/useGameTick"; import { markAllNotificationsRead, markNotificationsRead, @@ -41,7 +42,7 @@ function timeAgo(iso: string): string { return `${days}d ago`; } -const POLL_MS = 30_000; +const POLL_MS = 60_000; export function NotificationsBell() { const router = useRouter(); @@ -74,8 +75,11 @@ export function NotificationsBell() { useEffect(() => { void fetchNotifications(); intervalRef.current = setInterval(() => void fetchNotifications(), POLL_MS); + const onNotification = () => void fetchNotifications(); + window.addEventListener(NOTIFICATION_EVENT, onNotification); return () => { if (intervalRef.current) clearInterval(intervalRef.current); + window.removeEventListener(NOTIFICATION_EVENT, onNotification); }; }, [fetchNotifications]); diff --git a/src/components/frontend/realtime/GameTickRealtime.tsx b/src/components/frontend/realtime/GameTickRealtime.tsx index df65853..2fe8a77 100644 --- a/src/components/frontend/realtime/GameTickRealtime.tsx +++ b/src/components/frontend/realtime/GameTickRealtime.tsx @@ -2,7 +2,14 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { GAME_TICK_EVENT, type GameTickDetail } from "@/hooks/useGameTick"; +import { + GAME_EVENT_EVENT, + GAME_TICK_EVENT, + NOTIFICATION_EVENT, + PROFILE_UPDATE_EVENT, + type GameTickDetail, + type ProfileUpdateDetail, +} from "@/hooks/useGameTick"; /** * Opens a Server-Sent Events connection to the game tick stream. @@ -15,6 +22,14 @@ export function GameTickRealtime() { useEffect(() => { const source = new EventSource("/api/realtime"); + let refreshTimer: ReturnType | null = null; + const refresh = (delay = 250) => { + if (refreshTimer) return; + refreshTimer = setTimeout(() => { + refreshTimer = null; + router.refresh(); + }, delay); + }; source.addEventListener("game-tick", (event) => { let detail: GameTickDetail = {}; try { @@ -22,12 +37,32 @@ export function GameTickRealtime() { } catch { // ignore malformed payload } - router.refresh(); + refresh(); window.dispatchEvent( new CustomEvent(GAME_TICK_EVENT, { detail }), ); }); - return () => source.close(); + source.addEventListener("profile-update", (event) => { + let detail: ProfileUpdateDetail = {}; + try { + detail = JSON.parse(event.data ?? "{}"); + } catch { + // ignore malformed payload + } + refresh(detail.xpGained ? 1700 : 250); + window.dispatchEvent(new CustomEvent(PROFILE_UPDATE_EVENT, { detail })); + }); + source.addEventListener("game-event", () => { + refresh(); + window.dispatchEvent(new CustomEvent(GAME_EVENT_EVENT)); + }); + source.addEventListener("notification", (event) => { + window.dispatchEvent(new CustomEvent(NOTIFICATION_EVENT, { detail: event.data })); + }); + return () => { + if (refreshTimer) clearTimeout(refreshTimer); + source.close(); + }; }, [router]); return null; diff --git a/src/hooks/useGameTick.ts b/src/hooks/useGameTick.ts index ebef135..e238832 100644 --- a/src/hooks/useGameTick.ts +++ b/src/hooks/useGameTick.ts @@ -3,11 +3,19 @@ import { useEffect, useRef, useState } from "react"; export const GAME_TICK_EVENT = "ptf:game-tick"; +export const PROFILE_UPDATE_EVENT = "ptf:profile-update"; +export const GAME_EVENT_EVENT = "ptf:game-event"; +export const NOTIFICATION_EVENT = "ptf:notification"; export interface GameTickDetail { processedAt?: string; } +export interface ProfileUpdateDetail { + reason?: string; + xpGained?: number; +} + export function useGameTick(onTick?: () => void): number { const [count, setCount] = useState(0); const callbackRef = useRef(onTick); diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts index 1c7c737..148fb51 100644 --- a/src/lib/notifications/index.ts +++ b/src/lib/notifications/index.ts @@ -1,3 +1,5 @@ +import { broadcastToUser } from "@/lib/realtime/bus"; + type PayloadType = Awaited>; export interface NotifyInput { @@ -40,6 +42,7 @@ export async function notifyUser(payload: PayloadType, input: NotifyInput): Prom overrideAccess: true, depth: 0, }); + broadcastToUser("notification", input.userId, { type: input.type ?? null }); } catch (err) { console.error("[Notifications] Failed to create notification:", err); } diff --git a/src/lib/realtime/bus.ts b/src/lib/realtime/bus.ts index 5a09139..b99ec49 100644 --- a/src/lib/realtime/bus.ts +++ b/src/lib/realtime/bus.ts @@ -1,6 +1,7 @@ export interface SSEEventClient { send: (data: string) => void; events?: Set; + userId?: number; } const clients = new Set(); @@ -30,3 +31,18 @@ export function broadcast(event: string, data: unknown): number { } return clients.size; } + +export function broadcastToUser(event: string, userId: number, data: unknown): number { + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + const targetUserId = Number(userId); + for (const client of clients) { + if (client.userId !== targetUserId) continue; + if (client.events && !client.events.has(event)) continue; + try { + client.send(payload); + } catch { + clients.delete(client); + } + } + return clients.size; +} diff --git a/src/utils/event-log/emit.ts b/src/utils/event-log/emit.ts index b1d48ab..0b83ec4 100644 --- a/src/utils/event-log/emit.ts +++ b/src/utils/event-log/emit.ts @@ -1,4 +1,5 @@ import type { EventType } from "./eventTypes"; +import { broadcast } from "@/lib/realtime/bus"; interface GameEventInput { type: EventType; @@ -52,6 +53,12 @@ export async function emitGameEvent( }, depth: 0, }); + broadcast("game-event", { + type: event.type, + actor: event.actor ?? null, + targetCollection: event.targetCollection ?? null, + targetId: event.targetId ?? null, + }); } catch (err) { console.error("[EventLog] Failed to emit event:", err); }