diff --git a/src/app.ts b/src/app.ts index 92ebb9e..a985d60 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,7 +8,7 @@ import { prisma } from './lib/db' import { env } from './lib/env' import { createSystemLog } from './lib/logger' import { getMinioDownloadUrl, uploadBugImage } from './lib/minio' -import { addConnection, broadcastToAdmins, getOnlineUserIds, removeConnection } from './lib/presence' +import { addConnection, broadcastNotification, broadcastToAdmins, getOnlineUserIds, removeConnection } from './lib/presence' import { parseSchema } from './lib/schema-parser' const isProduction = process.env.NODE_ENV === 'production' @@ -921,6 +921,18 @@ export function createApp() { }, }) + broadcastNotification({ + type: 'new_bug', + bug: { + id: bug.id, + description: bug.description, + appId: bug.appId, + source: bug.source, + affectedVersion: bug.affectedVersion, + createdAt: bug.createdAt, + }, + }) + return bug }, { body: t.Object({ @@ -1241,9 +1253,11 @@ export function createApp() { include: { user: { select: { id: true, role: true } } }, }) if (!session || session.expiresAt < new Date()) { ws.close(4001, 'Unauthorized'); return } - const isAdmin = session.user.role === 'DEVELOPER' + const role = session.user.role + const isAdmin = role === 'DEVELOPER' + const canReceiveNotifs = role === 'DEVELOPER' || role === 'ADMIN' ;(ws.data as unknown as { userId: string }).userId = session.user.id - addConnection(ws as any, session.user.id, isAdmin) + addConnection(ws as any, session.user.id, isAdmin, canReceiveNotifs) }, close(ws) { removeConnection(ws as any) }, message() {}, diff --git a/src/frontend/components/DashboardLayout.tsx b/src/frontend/components/DashboardLayout.tsx index 9470c48..c424efa 100644 --- a/src/frontend/components/DashboardLayout.tsx +++ b/src/frontend/components/DashboardLayout.tsx @@ -1,5 +1,6 @@ import { APP_CONFIGS } from '@/frontend/config/appMenus' import { useLogout, useSession } from '@/frontend/hooks/useAuth' +import { usePresence } from '@/frontend/hooks/usePresence' import React from 'react' import { ActionIcon, @@ -24,12 +25,14 @@ import { useMantineColorScheme } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' +import { notifications } from '@mantine/notifications' import { useQuery } from '@tanstack/react-query' import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router' import { TbAlertTriangle, TbApps, TbArrowLeft, + TbBug, TbChevronRight, TbClock, TbDashboard, @@ -64,6 +67,20 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const user = sessionData?.user const logout = useLogout() + // ─── Real-time bug notifications ───────────────────── + usePresence((bug) => { + const appLabel = bug.appId ? bug.appId.toUpperCase() : 'Unknown App' + notifications.show({ + id: `new-bug-${bug.id}`, + title: `New bug report — ${appLabel}`, + message: bug.description.length > 80 ? `${bug.description.slice(0, 80)}…` : bug.description, + color: 'red', + icon: React.createElement(TbBug, { size: 18 }), + autoClose: 8000, + withBorder: true, + }) + }) + // Redirect USER role to profile (pending approval) React.useEffect(() => { if (!sessionLoading && user?.role === 'USER') { diff --git a/src/frontend/hooks/usePresence.ts b/src/frontend/hooks/usePresence.ts index 4ab296e..d5d97fe 100644 --- a/src/frontend/hooks/usePresence.ts +++ b/src/frontend/hooks/usePresence.ts @@ -1,11 +1,22 @@ import { useEffect, useRef, useState } from 'react' import { useSession } from './useAuth' -export function usePresence() { +export interface NewBugPayload { + id: string + description: string + appId: string | null + source: string + affectedVersion: string + createdAt: string +} + +export function usePresence(onNewBug?: (bug: NewBugPayload) => void) { const { data } = useSession() const [onlineUserIds, setOnlineUserIds] = useState([]) const wsRef = useRef(null) const reconnectTimer = useRef | undefined>(undefined) + const onNewBugRef = useRef(onNewBug) + onNewBugRef.current = onNewBug useEffect(() => { if (!data?.user) return @@ -18,6 +29,7 @@ export function usePresence() { ws.onmessage = (e) => { const msg = JSON.parse(e.data) if (msg.type === 'presence') setOnlineUserIds(msg.online) + if (msg.type === 'new_bug') onNewBugRef.current?.(msg.bug) } ws.onclose = () => { wsRef.current = null diff --git a/src/lib/presence.ts b/src/lib/presence.ts index c6cf064..10cc1a5 100644 --- a/src/lib/presence.ts +++ b/src/lib/presence.ts @@ -2,6 +2,7 @@ import type { ServerWebSocket } from 'bun' const connections = new Map>>() const adminSubs = new Set>() +const notifSubs = new Set>() export function getOnlineUserIds(): string[] { return Array.from(connections.keys()) @@ -13,7 +14,12 @@ function broadcast() { for (const ws of adminSubs) ws.send(msg) } -export function addConnection(ws: ServerWebSocket<{ userId: string }>, userId: string, isAdmin: boolean) { +export function addConnection( + ws: ServerWebSocket<{ userId: string }>, + userId: string, + isAdmin: boolean, + canReceiveNotifs: boolean, +) { let set = connections.get(userId) if (!set) { set = new Set() @@ -24,6 +30,7 @@ export function addConnection(ws: ServerWebSocket<{ userId: string }>, userId: s adminSubs.add(ws) ws.send(JSON.stringify({ type: 'presence', online: getOnlineUserIds() })) } + if (canReceiveNotifs) notifSubs.add(ws) broadcast() } @@ -32,6 +39,11 @@ export function broadcastToAdmins(message: object) { for (const ws of adminSubs) ws.send(msg) } +export function broadcastNotification(message: object) { + const msg = JSON.stringify(message) + for (const ws of notifSubs) ws.send(msg) +} + export function removeConnection(ws: ServerWebSocket<{ userId: string }>) { const userId = ws.data.userId const set = connections.get(userId) @@ -40,5 +52,6 @@ export function removeConnection(ws: ServerWebSocket<{ userId: string }>) { if (set.size === 0) connections.delete(userId) } adminSubs.delete(ws) + notifSubs.delete(ws) broadcast() }