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>
)
}

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
}

View File

@@ -3,13 +3,16 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './contexts/AuthContext'
import { NotificationProvider } from './contexts/NotificationContext'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<NotificationProvider>
<App />
</NotificationProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,

View File

@@ -0,0 +1,169 @@
import api from './api'
// Types
export interface Comment {
id: string
task_id: string
parent_comment_id: string | null
content: string
is_edited: boolean
is_deleted: boolean
created_at: string
updated_at: string
author: {
id: string
name: string
email: string
}
mentions: Array<{
id: string
name: string
email: string
}>
reply_count: number
}
export interface CommentListResponse {
comments: Comment[]
total: number
}
export interface Notification {
id: string
type: 'mention' | 'assignment' | 'blocker' | 'status_change' | 'comment' | 'blocker_resolved'
reference_type: string
reference_id: string
title: string
message: string | null
is_read: boolean
created_at: string
read_at: string | null
}
export interface NotificationListResponse {
notifications: Notification[]
total: number
unread_count: number
}
export interface Blocker {
id: string
task_id: string
reason: string
resolution_note: string | null
created_at: string
resolved_at: string | null
reporter: {
id: string
name: string
email: string
}
resolver: {
id: string
name: string
email: string
} | null
}
export interface BlockerListResponse {
blockers: Blocker[]
total: number
}
export interface UserSearchResult {
id: string
email: string
name: string
}
// Comments API
export const commentsApi = {
list: async (taskId: string): Promise<CommentListResponse> => {
const response = await api.get<CommentListResponse>(`/tasks/${taskId}/comments`)
return response.data
},
listReplies: async (commentId: string): Promise<CommentListResponse> => {
const response = await api.get<CommentListResponse>(`/comments/${commentId}/replies`)
return response.data
},
create: async (taskId: string, content: string, parentCommentId?: string): Promise<Comment> => {
const response = await api.post<Comment>(`/tasks/${taskId}/comments`, {
content,
parent_comment_id: parentCommentId,
})
return response.data
},
update: async (commentId: string, content: string): Promise<Comment> => {
const response = await api.put<Comment>(`/comments/${commentId}`, { content })
return response.data
},
delete: async (commentId: string): Promise<void> => {
await api.delete(`/comments/${commentId}`)
},
}
// Notifications API
export const notificationsApi = {
list: async (isRead?: boolean, limit = 50, offset = 0): Promise<NotificationListResponse> => {
const params: Record<string, unknown> = { limit, offset }
if (isRead !== undefined) {
params.is_read = isRead
}
const response = await api.get<NotificationListResponse>('/notifications', { params })
return response.data
},
getUnreadCount: async (): Promise<number> => {
const response = await api.get<{ unread_count: number }>('/notifications/unread-count')
return response.data.unread_count
},
markAsRead: async (notificationId: string): Promise<Notification> => {
const response = await api.put<Notification>(`/notifications/${notificationId}/read`)
return response.data
},
markAllAsRead: async (): Promise<{ updated_count: number }> => {
const response = await api.put<{ updated_count: number }>('/notifications/read-all')
return response.data
},
}
// Blockers API
export const blockersApi = {
list: async (taskId: string): Promise<BlockerListResponse> => {
const response = await api.get<BlockerListResponse>(`/tasks/${taskId}/blockers`)
return response.data
},
create: async (taskId: string, reason: string): Promise<Blocker> => {
const response = await api.post<Blocker>(`/tasks/${taskId}/blockers`, { reason })
return response.data
},
resolve: async (blockerId: string, resolutionNote: string): Promise<Blocker> => {
const response = await api.put<Blocker>(`/blockers/${blockerId}/resolve`, {
resolution_note: resolutionNote,
})
return response.data
},
get: async (blockerId: string): Promise<Blocker> => {
const response = await api.get<Blocker>(`/blockers/${blockerId}`)
return response.data
},
}
// User Search API (for @mention)
export const usersApi = {
search: async (query: string, limit = 10): Promise<UserSearchResult[]> => {
const response = await api.get<UserSearchResult[]>('/users/search', {
params: { q: query, limit },
})
return response.data
},
}