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,218 @@
import { useState, useEffect } from 'react'
import { blockersApi, Blocker } from '../services/collaboration'
interface BlockerDialogProps {
taskId: string
taskTitle: string
isBlocked: boolean
onClose: () => void
onBlockerChange: () => void
}
export function BlockerDialog({
taskId,
taskTitle,
isBlocked,
onClose,
onBlockerChange,
}: BlockerDialogProps) {
const [blockers, setBlockers] = useState<Blocker[]>([])
const [loading, setLoading] = useState(true)
const [reason, setReason] = useState('')
const [resolutionNote, setResolutionNote] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [activeBlocker, setActiveBlocker] = useState<Blocker | null>(null)
useEffect(() => {
const fetchBlockers = async () => {
try {
const response = await blockersApi.list(taskId)
setBlockers(response.blockers)
// Find active blocker (unresolved)
const active = response.blockers.find(b => !b.resolved_at)
setActiveBlocker(active || null)
} catch (err) {
setError('Failed to load blockers')
} finally {
setLoading(false)
}
}
fetchBlockers()
}, [taskId])
const handleCreateBlocker = async (e: React.FormEvent) => {
e.preventDefault()
if (!reason.trim()) return
try {
setSubmitting(true)
const blocker = await blockersApi.create(taskId, reason)
setBlockers(prev => [blocker, ...prev])
setActiveBlocker(blocker)
setReason('')
setError(null)
onBlockerChange()
} catch (err) {
setError('Failed to create blocker')
} finally {
setSubmitting(false)
}
}
const handleResolveBlocker = async (e: React.FormEvent) => {
e.preventDefault()
if (!activeBlocker || !resolutionNote.trim()) return
try {
setSubmitting(true)
const updated = await blockersApi.resolve(activeBlocker.id, resolutionNote)
setBlockers(prev => prev.map(b => (b.id === updated.id ? updated : b)))
setActiveBlocker(null)
setResolutionNote('')
setError(null)
onBlockerChange()
} catch (err) {
setError('Failed to resolve blocker')
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden">
<div className="p-4 border-b flex justify-between items-center">
<h2 className="text-lg font-semibold">
{isBlocked ? 'Task Blocked' : 'Mark as Blocked'}
</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="p-4 overflow-y-auto max-h-[60vh]">
<p className="text-gray-600 mb-4">Task: {taskTitle}</p>
{error && (
<div className="p-2 bg-red-100 text-red-700 rounded text-sm mb-4">
{error}
</div>
)}
{loading ? (
<div className="text-center text-gray-500 py-4">Loading...</div>
) : (
<>
{/* Active blocker */}
{activeBlocker && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<h3 className="font-semibold text-red-800 mb-2">
Active Blocker
</h3>
<p className="text-gray-700 mb-2">{activeBlocker.reason}</p>
<p className="text-sm text-gray-500">
Reported by {activeBlocker.reporter.name} on{' '}
{new Date(activeBlocker.created_at).toLocaleString()}
</p>
{/* Resolve form */}
<form onSubmit={handleResolveBlocker} className="mt-4">
<label className="block text-sm font-medium mb-1">
Resolution Note
</label>
<textarea
value={resolutionNote}
onChange={e => setResolutionNote(e.target.value)}
placeholder="Describe how the blocker was resolved..."
className="w-full p-2 border rounded resize-none"
rows={3}
required
/>
<button
type="submit"
disabled={submitting || !resolutionNote.trim()}
className="mt-2 px-4 py-2 bg-green-600 text-white rounded disabled:opacity-50"
>
{submitting ? 'Resolving...' : 'Resolve Blocker'}
</button>
</form>
</div>
)}
{/* Create blocker form */}
{!activeBlocker && (
<form onSubmit={handleCreateBlocker} className="mb-4">
<label className="block text-sm font-medium mb-1">
Blocker Reason
</label>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Describe what is blocking this task..."
className="w-full p-2 border rounded resize-none"
rows={3}
required
/>
<button
type="submit"
disabled={submitting || !reason.trim()}
className="mt-2 px-4 py-2 bg-red-600 text-white rounded disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Mark as Blocked'}
</button>
</form>
)}
{/* History */}
{blockers.filter(b => b.resolved_at).length > 0 && (
<div className="mt-4">
<h3 className="font-semibold mb-2">Blocker History</h3>
<div className="space-y-2">
{blockers
.filter(b => b.resolved_at)
.map(blocker => (
<div
key={blocker.id}
className="bg-gray-50 border rounded p-3 text-sm"
>
<p className="font-medium">{blocker.reason}</p>
<p className="text-gray-500 mt-1">
Reported: {blocker.reporter.name} {' '}
{new Date(blocker.created_at).toLocaleDateString()}
</p>
{blocker.resolver && (
<p className="text-green-600 mt-1">
Resolved: {blocker.resolver.name} {' '}
{new Date(blocker.resolved_at!).toLocaleDateString()}
</p>
)}
{blocker.resolution_note && (
<p className="text-gray-600 mt-1 italic">
"{blocker.resolution_note}"
</p>
)}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
<div className="p-4 border-t flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Close
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,258 @@
import { useState, useEffect, useCallback } from 'react'
import { commentsApi, usersApi, Comment, UserSearchResult } from '../services/collaboration'
import { useAuth } from '../contexts/AuthContext'
interface CommentsProps {
taskId: string
}
export function Comments({ taskId }: CommentsProps) {
const { user } = useAuth()
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [newComment, setNewComment] = useState('')
const [submitting, setSubmitting] = useState(false)
const [replyTo, setReplyTo] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editContent, setEditContent] = useState('')
const [mentionSuggestions, setMentionSuggestions] = useState<UserSearchResult[]>([])
const [showMentions, setShowMentions] = useState(false)
const fetchComments = useCallback(async () => {
try {
setLoading(true)
const response = await commentsApi.list(taskId)
setComments(response.comments)
setError(null)
} catch (err) {
setError('Failed to load comments')
} finally {
setLoading(false)
}
}, [taskId])
useEffect(() => {
fetchComments()
}, [fetchComments])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!newComment.trim() || submitting) return
try {
setSubmitting(true)
const comment = await commentsApi.create(taskId, newComment, replyTo || undefined)
if (replyTo) {
// For replies, we'd need to refresh or update the parent
await fetchComments()
} else {
setComments(prev => [...prev, comment])
}
setNewComment('')
setReplyTo(null)
} catch (err) {
setError('Failed to post comment')
} finally {
setSubmitting(false)
}
}
const handleEdit = async (commentId: string) => {
if (!editContent.trim()) return
try {
const updated = await commentsApi.update(commentId, editContent)
setComments(prev => prev.map(c => (c.id === commentId ? updated : c)))
setEditingId(null)
setEditContent('')
} catch (err) {
setError('Failed to update comment')
}
}
const handleDelete = async (commentId: string) => {
if (!confirm('Delete this comment?')) return
try {
await commentsApi.delete(commentId)
await fetchComments()
} catch (err) {
setError('Failed to delete comment')
}
}
const handleMentionSearch = async (query: string) => {
if (query.length < 1) {
setMentionSuggestions([])
setShowMentions(false)
return
}
try {
const results = await usersApi.search(query)
setMentionSuggestions(results)
setShowMentions(results.length > 0)
} catch {
setMentionSuggestions([])
}
}
const handleInputChange = (value: string) => {
setNewComment(value)
// Check for @mention
const atMatch = value.match(/@(\w*)$/)
if (atMatch) {
handleMentionSearch(atMatch[1])
} else {
setShowMentions(false)
}
}
const insertMention = (userResult: UserSearchResult) => {
const newValue = newComment.replace(/@\w*$/, `@${userResult.name} `)
setNewComment(newValue)
setShowMentions(false)
}
if (loading) return <div className="p-4 text-gray-500">Loading comments...</div>
return (
<div className="space-y-4">
<h3 className="font-semibold text-lg">Comments ({comments.length})</h3>
{error && (
<div className="p-2 bg-red-100 text-red-700 rounded text-sm">{error}</div>
)}
{/* Comment list */}
<div className="space-y-3">
{comments.map(comment => (
<div key={comment.id} className="border rounded-lg p-3 bg-gray-50">
<div className="flex justify-between items-start mb-2">
<div>
<span className="font-medium">{comment.author.name}</span>
<span className="text-gray-500 text-sm ml-2">
{new Date(comment.created_at).toLocaleString()}
</span>
{comment.is_edited && (
<span className="text-gray-400 text-xs ml-1">(edited)</span>
)}
</div>
{user?.id === comment.author.id && !comment.is_deleted && (
<div className="flex gap-2">
<button
onClick={() => {
setEditingId(comment.id)
setEditContent(comment.content)
}}
className="text-sm text-blue-600 hover:underline"
>
Edit
</button>
<button
onClick={() => handleDelete(comment.id)}
className="text-sm text-red-600 hover:underline"
>
Delete
</button>
</div>
)}
</div>
{editingId === comment.id ? (
<div className="space-y-2">
<textarea
value={editContent}
onChange={e => setEditContent(e.target.value)}
className="w-full p-2 border rounded"
rows={2}
/>
<div className="flex gap-2">
<button
onClick={() => handleEdit(comment.id)}
className="px-3 py-1 bg-blue-600 text-white rounded text-sm"
>
Save
</button>
<button
onClick={() => setEditingId(null)}
className="px-3 py-1 bg-gray-300 rounded text-sm"
>
Cancel
</button>
</div>
</div>
) : (
<p className={`whitespace-pre-wrap ${comment.is_deleted ? 'text-gray-400 italic' : ''}`}>
{comment.content}
</p>
)}
{/* Mentions */}
{comment.mentions.length > 0 && (
<div className="mt-2 text-sm text-gray-500">
Mentioned: {comment.mentions.map(m => m.name).join(', ')}
</div>
)}
{/* Reply button */}
{!comment.is_deleted && (
<button
onClick={() => setReplyTo(comment.id)}
className="text-sm text-blue-600 hover:underline mt-2"
>
Reply {comment.reply_count > 0 && `(${comment.reply_count})`}
</button>
)}
</div>
))}
</div>
{/* New comment form */}
<form onSubmit={handleSubmit} className="space-y-2">
{replyTo && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>Replying to comment</span>
<button
type="button"
onClick={() => setReplyTo(null)}
className="text-red-600 hover:underline"
>
Cancel
</button>
</div>
)}
<div className="relative">
<textarea
value={newComment}
onChange={e => handleInputChange(e.target.value)}
placeholder="Add a comment... Use @name to mention someone"
className="w-full p-3 border rounded-lg resize-none"
rows={3}
/>
{/* Mention suggestions dropdown */}
{showMentions && (
<div className="absolute bottom-full left-0 w-full bg-white border rounded shadow-lg max-h-40 overflow-y-auto">
{mentionSuggestions.map(u => (
<button
key={u.id}
type="button"
onClick={() => insertMention(u)}
className="w-full text-left px-3 py-2 hover:bg-gray-100"
>
<span className="font-medium">{u.name}</span>
<span className="text-gray-500 text-sm ml-2">{u.email}</span>
</button>
))}
</div>
)}
</div>
<button
type="submit"
disabled={!newComment.trim() || submitting}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{submitting ? 'Posting...' : 'Post Comment'}
</button>
</form>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { ReactNode } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { NotificationBell } from './NotificationBell'
interface LayoutProps {
children: ReactNode
@@ -43,6 +44,7 @@ export default function Layout({ children }: LayoutProps) {
</nav>
</div>
<div style={styles.headerRight}>
<NotificationBell />
<span style={styles.userName}>{user?.name}</span>
{user?.is_system_admin && (
<span style={styles.badge}>Admin</span>

View File

@@ -0,0 +1,155 @@
import { useState, useRef, useEffect } from 'react'
import { useNotifications } from '../contexts/NotificationContext'
export function NotificationBell() {
const { notifications, unreadCount, loading, fetchNotifications, markAsRead, markAllAsRead } =
useNotifications()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleOpen = async () => {
setIsOpen(!isOpen)
if (!isOpen && notifications.length === 0) {
await fetchNotifications()
}
}
const getNotificationIcon = (type: string) => {
switch (type) {
case 'mention':
return '@'
case 'assignment':
return '👤'
case 'blocker':
return '🚫'
case 'blocker_resolved':
return '✅'
case 'comment':
return '💬'
default:
return '🔔'
}
}
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={handleOpen}
className="relative p-2 text-gray-600 hover:text-gray-900 focus:outline-none"
aria-label="Notifications"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border z-50">
<div className="p-3 border-b flex justify-between items-center">
<h3 className="font-semibold">Notifications</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllAsRead()}
className="text-sm text-blue-600 hover:underline"
>
Mark all read
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="p-4 text-center text-gray-500">Loading...</div>
) : notifications.length === 0 ? (
<div className="p-4 text-center text-gray-500">No notifications</div>
) : (
notifications.map(notification => (
<div
key={notification.id}
onClick={() => !notification.is_read && markAsRead(notification.id)}
className={`p-3 border-b cursor-pointer hover:bg-gray-50 ${
!notification.is_read ? 'bg-blue-50' : ''
}`}
>
<div className="flex gap-3">
<span className="text-xl">
{getNotificationIcon(notification.type)}
</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{notification.title}
</p>
{notification.message && (
<p className="text-gray-600 text-sm truncate">
{notification.message}
</p>
)}
<p className="text-gray-400 text-xs mt-1">
{formatTime(notification.created_at)}
</p>
</div>
{!notification.is_read && (
<span className="w-2 h-2 bg-blue-600 rounded-full flex-shrink-0 mt-2" />
)}
</div>
</div>
))
)}
</div>
{notifications.length > 0 && (
<div className="p-2 border-t text-center">
<button
onClick={() => fetchNotifications()}
className="text-sm text-blue-600 hover:underline"
>
Refresh
</button>
</div>
)}
</div>
)}
</div>
)
}