feat: notifikasi real-time bug baru via WebSocket

- presence.ts: tambah notifSubs (ADMIN+DEVELOPER) dan broadcastNotification
- app.ts: broadcast new_bug event setelah bug dibuat, update WS handler
- usePresence: terima callback onNewBug, expose NewBugPayload type
- DashboardLayout: pasang usePresence, tampilkan Mantine notification saat bug baru masuk
This commit is contained in:
2026-05-25 11:35:21 +08:00
parent 8c33003b17
commit e32addbc85
4 changed files with 61 additions and 5 deletions

View File

@@ -8,7 +8,7 @@ import { prisma } from './lib/db'
import { env } from './lib/env' import { env } from './lib/env'
import { createSystemLog } from './lib/logger' import { createSystemLog } from './lib/logger'
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio' 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' import { parseSchema } from './lib/schema-parser'
const isProduction = process.env.NODE_ENV === 'production' 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 return bug
}, { }, {
body: t.Object({ body: t.Object({
@@ -1241,9 +1253,11 @@ export function createApp() {
include: { user: { select: { id: true, role: true } } }, include: { user: { select: { id: true, role: true } } },
}) })
if (!session || session.expiresAt < new Date()) { ws.close(4001, 'Unauthorized'); return } 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 ;(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) }, close(ws) { removeConnection(ws as any) },
message() {}, message() {},

View File

@@ -1,5 +1,6 @@
import { APP_CONFIGS } from '@/frontend/config/appMenus' import { APP_CONFIGS } from '@/frontend/config/appMenus'
import { useLogout, useSession } from '@/frontend/hooks/useAuth' import { useLogout, useSession } from '@/frontend/hooks/useAuth'
import { usePresence } from '@/frontend/hooks/usePresence'
import React from 'react' import React from 'react'
import { import {
ActionIcon, ActionIcon,
@@ -24,12 +25,14 @@ import {
useMantineColorScheme useMantineColorScheme
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router' import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
import { import {
TbAlertTriangle, TbAlertTriangle,
TbApps, TbApps,
TbArrowLeft, TbArrowLeft,
TbBug,
TbChevronRight, TbChevronRight,
TbClock, TbClock,
TbDashboard, TbDashboard,
@@ -64,6 +67,20 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const user = sessionData?.user const user = sessionData?.user
const logout = useLogout() 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) // Redirect USER role to profile (pending approval)
React.useEffect(() => { React.useEffect(() => {
if (!sessionLoading && user?.role === 'USER') { if (!sessionLoading && user?.role === 'USER') {

View File

@@ -1,11 +1,22 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useSession } from './useAuth' 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 { data } = useSession()
const [onlineUserIds, setOnlineUserIds] = useState<string[]>([]) const [onlineUserIds, setOnlineUserIds] = useState<string[]>([])
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined) const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const onNewBugRef = useRef(onNewBug)
onNewBugRef.current = onNewBug
useEffect(() => { useEffect(() => {
if (!data?.user) return if (!data?.user) return
@@ -18,6 +29,7 @@ export function usePresence() {
ws.onmessage = (e) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data) const msg = JSON.parse(e.data)
if (msg.type === 'presence') setOnlineUserIds(msg.online) if (msg.type === 'presence') setOnlineUserIds(msg.online)
if (msg.type === 'new_bug') onNewBugRef.current?.(msg.bug)
} }
ws.onclose = () => { ws.onclose = () => {
wsRef.current = null wsRef.current = null

View File

@@ -2,6 +2,7 @@ import type { ServerWebSocket } from 'bun'
const connections = new Map<string, Set<ServerWebSocket<{ userId: string }>>>() const connections = new Map<string, Set<ServerWebSocket<{ userId: string }>>>()
const adminSubs = new Set<ServerWebSocket<{ userId: string }>>() const adminSubs = new Set<ServerWebSocket<{ userId: string }>>()
const notifSubs = new Set<ServerWebSocket<{ userId: string }>>()
export function getOnlineUserIds(): string[] { export function getOnlineUserIds(): string[] {
return Array.from(connections.keys()) return Array.from(connections.keys())
@@ -13,7 +14,12 @@ function broadcast() {
for (const ws of adminSubs) ws.send(msg) 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) let set = connections.get(userId)
if (!set) { if (!set) {
set = new Set() set = new Set()
@@ -24,6 +30,7 @@ export function addConnection(ws: ServerWebSocket<{ userId: string }>, userId: s
adminSubs.add(ws) adminSubs.add(ws)
ws.send(JSON.stringify({ type: 'presence', online: getOnlineUserIds() })) ws.send(JSON.stringify({ type: 'presence', online: getOnlineUserIds() }))
} }
if (canReceiveNotifs) notifSubs.add(ws)
broadcast() broadcast()
} }
@@ -32,6 +39,11 @@ export function broadcastToAdmins(message: object) {
for (const ws of adminSubs) ws.send(msg) 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 }>) { export function removeConnection(ws: ServerWebSocket<{ userId: string }>) {
const userId = ws.data.userId const userId = ws.data.userId
const set = connections.get(userId) const set = connections.get(userId)
@@ -40,5 +52,6 @@ export function removeConnection(ws: ServerWebSocket<{ userId: string }>) {
if (set.size === 0) connections.delete(userId) if (set.size === 0) connections.delete(userId)
} }
adminSubs.delete(ws) adminSubs.delete(ws)
notifSubs.delete(ws)
broadcast() broadcast()
} }