feat: implement collaboration module

- Backend (FastAPI):
  - Task comments with nested replies and soft delete
  - @mention parsing with 10-mention limit per comment
  - Notification system with read/unread tracking
  - Blocker management with project owner notification
  - WebSocket endpoint with JWT auth and keepalive
  - User search API for @mention autocomplete
  - Alembic migration for 4 new tables

- Frontend (React + Vite):
  - Comments component with @mention autocomplete
  - NotificationBell with real-time WebSocket updates
  - BlockerDialog for task blocking workflow
  - NotificationContext for state management

- OpenSpec:
  - 4 requirements with scenarios defined
  - add-collaboration change archived

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-12-29 20:45:07 +08:00
parent 61fe01cb6b
commit 3470428411
38 changed files with 3088 additions and 4 deletions

View File

@@ -0,0 +1,191 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode, useRef } from 'react'
import { notificationsApi, Notification, NotificationListResponse } from '../services/collaboration'
interface NotificationContextType {
notifications: Notification[]
unreadCount: number
loading: boolean
error: string | null
fetchNotifications: (isRead?: boolean) => Promise<void>
markAsRead: (notificationId: string) => Promise<void>
markAllAsRead: () => Promise<void>
refreshUnreadCount: () => Promise<void>
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined)
const WS_RECONNECT_DELAY = 3000
const WS_PING_INTERVAL = 30000
export function NotificationProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const refreshUnreadCount = useCallback(async () => {
try {
const count = await notificationsApi.getUnreadCount()
setUnreadCount(count)
} catch (err) {
console.error('Failed to fetch unread count:', err)
}
}, [])
const fetchNotifications = useCallback(async (isRead?: boolean) => {
setLoading(true)
setError(null)
try {
const response: NotificationListResponse = await notificationsApi.list(isRead)
setNotifications(response.notifications)
setUnreadCount(response.unread_count)
} catch (err) {
setError('Failed to load notifications')
console.error('Failed to fetch notifications:', err)
} finally {
setLoading(false)
}
}, [])
const markAsRead = useCallback(async (notificationId: string) => {
try {
const updated = await notificationsApi.markAsRead(notificationId)
setNotifications(prev =>
prev.map(n => (n.id === notificationId ? updated : n))
)
setUnreadCount(prev => Math.max(0, prev - 1))
} catch (err) {
console.error('Failed to mark as read:', err)
}
}, [])
const markAllAsRead = useCallback(async () => {
try {
await notificationsApi.markAllAsRead()
setNotifications(prev =>
prev.map(n => ({ ...n, is_read: true, read_at: new Date().toISOString() }))
)
setUnreadCount(0)
} catch (err) {
console.error('Failed to mark all as read:', err)
}
}, [])
// WebSocket connection
const connectWebSocket = useCallback(() => {
const token = localStorage.getItem('token')
if (!token) return
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProtocol}//${window.location.host}/ws/notifications?token=${token}`
try {
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
console.log('WebSocket connected')
// Start ping interval
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
}
}, WS_PING_INTERVAL)
}
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
switch (message.type) {
case 'notification':
// Add new notification to the top
setNotifications(prev => [message.data, ...prev])
setUnreadCount(prev => prev + 1)
break
case 'unread_count':
setUnreadCount(message.data.unread_count)
break
case 'pong':
// Pong received, connection is alive
break
}
} catch (err) {
console.error('Failed to parse WebSocket message:', err)
}
}
ws.onclose = () => {
console.log('WebSocket disconnected')
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = null
}
// Attempt to reconnect after delay
reconnectTimeoutRef.current = setTimeout(() => {
connectWebSocket()
}, WS_RECONNECT_DELAY)
}
ws.onerror = (err) => {
console.error('WebSocket error:', err)
}
} catch (err) {
console.error('Failed to create WebSocket:', err)
}
}, [])
// Initial fetch and WebSocket connection
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
refreshUnreadCount()
connectWebSocket()
}
return () => {
// Cleanup on unmount
if (wsRef.current) {
wsRef.current.close()
}
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
}
}, [refreshUnreadCount, connectWebSocket])
return (
<NotificationContext.Provider
value={{
notifications,
unreadCount,
loading,
error,
fetchNotifications,
markAsRead,
markAllAsRead,
refreshUnreadCount,
}}
>
{children}
</NotificationContext.Provider>
)
}
export function useNotifications() {
const context = useContext(NotificationContext)
if (context === undefined) {
throw new Error('useNotifications must be used within a NotificationProvider')
}
return context
}