diff --git a/backend/app/api/blockers/router.py b/backend/app/api/blockers/router.py index 36a1dcb..cd10fa3 100644 --- a/backend/app/api/blockers/router.py +++ b/backend/app/api/blockers/router.py @@ -85,7 +85,7 @@ async def create_blocker( # Update task blocker_flag task.blocker_flag = True - # Notify project owner + # Notify project owner (auto-publishes after commit) NotificationService.notify_blocker(db, task, current_user, blocker_data.reason) # Audit log @@ -150,7 +150,7 @@ async def resolve_blocker( if other_blockers == 0: task.blocker_flag = False - # Notify reporter that blocker is resolved + # Notify reporter that blocker is resolved (auto-publishes after commit) NotificationService.notify_blocker_resolved(db, task, current_user, blocker.reported_by) db.commit() diff --git a/backend/app/api/comments/router.py b/backend/app/api/comments/router.py index 64f721b..8ae41a5 100644 --- a/backend/app/api/comments/router.py +++ b/backend/app/api/comments/router.py @@ -104,10 +104,10 @@ async def create_comment( db.add(comment) db.flush() - # Process mentions and create notifications + # Process mentions and create notifications (auto-publishes after commit) NotificationService.process_mentions(db, comment, task, current_user) - # Notify parent comment author if this is a reply + # Notify parent comment author if this is a reply (auto-publishes after commit) if parent_author_id: NotificationService.notify_comment_reply(db, comment, task, current_user, parent_author_id) diff --git a/backend/app/api/websocket/router.py b/backend/app/api/websocket/router.py index 8c66c2e..1a0704b 100644 --- a/backend/app/api/websocket/router.py +++ b/backend/app/api/websocket/router.py @@ -1,15 +1,23 @@ import asyncio +import logging +import time from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query from sqlalchemy.orm import Session from app.core.database import SessionLocal from app.core.security import decode_access_token from app.core.redis import get_redis_sync -from app.models import User +from app.models import User, Notification from app.services.websocket_manager import manager +from app.core.redis_pubsub import NotificationSubscriber +logger = logging.getLogger(__name__) router = APIRouter(tags=["websocket"]) +# Heartbeat configuration +PING_INTERVAL = 60.0 # Send ping after this many seconds of no messages +PONG_TIMEOUT = 30.0 # Disconnect if no pong received within this time after ping + async def get_user_from_token(token: str) -> tuple[str | None, User | None]: """Validate token and return user_id and user object.""" @@ -38,6 +46,46 @@ async def get_user_from_token(token: str) -> tuple[str | None, User | None]: db.close() +async def get_unread_notifications(user_id: str) -> list[dict]: + """Query all unread notifications for a user.""" + db = SessionLocal() + try: + notifications = ( + db.query(Notification) + .filter(Notification.user_id == user_id, Notification.is_read == False) + .order_by(Notification.created_at.desc()) + .all() + ) + return [ + { + "id": n.id, + "type": n.type, + "reference_type": n.reference_type, + "reference_id": n.reference_id, + "title": n.title, + "message": n.message, + "is_read": n.is_read, + "created_at": n.created_at.isoformat() if n.created_at else None, + } + for n in notifications + ] + finally: + db.close() + + +async def get_unread_count(user_id: str) -> int: + """Get the count of unread notifications for a user.""" + db = SessionLocal() + try: + return ( + db.query(Notification) + .filter(Notification.user_id == user_id, Notification.is_read == False) + .count() + ) + finally: + db.close() + + @router.websocket("/ws/notifications") async def websocket_notifications( websocket: WebSocket, @@ -49,12 +97,15 @@ async def websocket_notifications( Connect with: ws://host/ws/notifications?token= Messages sent by server: + - {"type": "connected", "data": {"user_id": "...", "message": "..."}} - Connection success + - {"type": "unread_sync", "data": {"notifications": [...], "unread_count": N}} - All unread on connect - {"type": "notification", "data": {...}} - New notification - {"type": "unread_count", "data": {"unread_count": N}} - Unread count update - - {"type": "pong"} - Response to ping + - {"type": "ping"} - Server keepalive ping + - {"type": "pong"} - Response to client ping Messages accepted from client: - - {"type": "ping"} - Keepalive ping + - {"type": "ping"} - Client keepalive ping """ user_id, user = await get_user_from_token(token) @@ -63,6 +114,25 @@ async def websocket_notifications( return await manager.connect(websocket, user_id) + subscriber = NotificationSubscriber(user_id) + + async def handle_redis_message(notification_data: dict): + """Forward Redis pub/sub messages to WebSocket.""" + try: + await websocket.send_json({ + "type": "notification", + "data": notification_data, + }) + # Also send updated unread count + unread_count = await get_unread_count(user_id) + await websocket.send_json({ + "type": "unread_count", + "data": {"unread_count": unread_count}, + }) + except Exception as e: + logger.error(f"Error forwarding notification to WebSocket: {e}") + + redis_task = None try: # Send initial connection success message @@ -71,28 +141,88 @@ async def websocket_notifications( "data": {"user_id": user_id, "message": "Connected to notification service"}, }) + # Send all unread notifications on connect (unread_sync) + unread_notifications = await get_unread_notifications(user_id) + await websocket.send_json({ + "type": "unread_sync", + "data": { + "notifications": unread_notifications, + "unread_count": len(unread_notifications), + }, + }) + + # Start Redis pub/sub subscription in background + await subscriber.start() + redis_task = asyncio.create_task(subscriber.listen(handle_redis_message)) + + # Heartbeat tracking + waiting_for_pong = False + ping_sent_at = 0.0 + last_activity = time.time() + while True: + # Calculate appropriate timeout based on state + if waiting_for_pong: + # When waiting for pong, use remaining pong timeout + remaining = PONG_TIMEOUT - (time.time() - ping_sent_at) + if remaining <= 0: + logger.warning(f"Pong timeout for user {user_id}, disconnecting") + break + timeout = remaining + else: + # When not waiting, use remaining ping interval + remaining = PING_INTERVAL - (time.time() - last_activity) + if remaining <= 0: + # Time to send ping immediately + try: + await websocket.send_json({"type": "ping"}) + waiting_for_pong = True + ping_sent_at = time.time() + last_activity = ping_sent_at + timeout = PONG_TIMEOUT + except Exception: + break + else: + timeout = remaining + try: - # Wait for messages from client (ping/pong for keepalive) + # Wait for messages from client data = await asyncio.wait_for( websocket.receive_json(), - timeout=60.0 # 60 second timeout + timeout=timeout ) - # Handle ping message - if data.get("type") == "ping": + last_activity = time.time() + msg_type = data.get("type") + + # Handle ping message from client + if msg_type == "ping": await websocket.send_json({"type": "pong"}) + # Handle pong message from client (response to our ping) + elif msg_type == "pong": + waiting_for_pong = False + logger.debug(f"Pong received from user {user_id}") + except asyncio.TimeoutError: - # Send keepalive ping if no message received - try: - await websocket.send_json({"type": "ping"}) - except Exception: - break + if waiting_for_pong: + # Strict timeout check + if time.time() - ping_sent_at >= PONG_TIMEOUT: + logger.warning(f"Pong timeout for user {user_id}, disconnecting") + break + # If not waiting_for_pong, loop will handle sending ping at top except WebSocketDisconnect: pass - except Exception: - pass + except Exception as e: + logger.error(f"WebSocket error: {e}") finally: + # Clean up Redis subscription + if redis_task: + redis_task.cancel() + try: + await redis_task + except asyncio.CancelledError: + pass + await subscriber.stop() await manager.disconnect(websocket, user_id) diff --git a/backend/app/core/redis_pubsub.py b/backend/app/core/redis_pubsub.py new file mode 100644 index 0000000..ca1361e --- /dev/null +++ b/backend/app/core/redis_pubsub.py @@ -0,0 +1,122 @@ +"""Redis Pub/Sub service for cross-process notification broadcasting.""" + +import json +import logging +from typing import Optional, Callable, Any +import redis.asyncio as aioredis + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +# Global async Redis client for pub/sub +_pubsub_redis: Optional[aioredis.Redis] = None + + +def get_channel_name(user_id: str) -> str: + """Get the Redis channel name for a user's notifications.""" + return f"notifications:{user_id}" + + +async def get_pubsub_redis() -> aioredis.Redis: + """Get or create the async Redis client for pub/sub.""" + global _pubsub_redis + if _pubsub_redis is None: + _pubsub_redis = aioredis.from_url( + settings.REDIS_URL, + encoding="utf-8", + decode_responses=True, + ) + return _pubsub_redis + + +async def close_pubsub_redis() -> None: + """Close the async Redis client.""" + global _pubsub_redis + if _pubsub_redis is not None: + await _pubsub_redis.close() + _pubsub_redis = None + + +async def publish_notification(user_id: str, notification: dict) -> bool: + """ + Publish a notification to a user's channel. + + Args: + user_id: The user ID to send the notification to + notification: The notification data (will be JSON serialized) + + Returns: + True if published successfully, False otherwise + """ + try: + redis_client = await get_pubsub_redis() + channel = get_channel_name(user_id) + message = json.dumps(notification, default=str) + await redis_client.publish(channel, message) + logger.debug(f"Published notification to channel {channel}") + return True + except Exception as e: + logger.error(f"Failed to publish notification: {e}") + return False + + +class NotificationSubscriber: + """ + Subscriber for user notification channels. + Used by WebSocket connections to receive real-time updates. + """ + + def __init__(self, user_id: str): + self.user_id = user_id + self.channel = get_channel_name(user_id) + self.pubsub: Optional[aioredis.client.PubSub] = None + self._running = False + + async def start(self) -> None: + """Start subscribing to the user's notification channel.""" + redis_client = await get_pubsub_redis() + self.pubsub = redis_client.pubsub() + await self.pubsub.subscribe(self.channel) + self._running = True + logger.debug(f"Subscribed to channel {self.channel}") + + async def stop(self) -> None: + """Stop subscribing and clean up.""" + self._running = False + if self.pubsub: + await self.pubsub.unsubscribe(self.channel) + await self.pubsub.close() + self.pubsub = None + logger.debug(f"Unsubscribed from channel {self.channel}") + + async def listen(self, callback: Callable[[dict], Any]) -> None: + """ + Listen for messages and call the callback for each notification. + + Args: + callback: Async function to call with each notification dict + """ + if not self.pubsub: + raise RuntimeError("Subscriber not started. Call start() first.") + + try: + async for message in self.pubsub.listen(): + if not self._running: + break + + if message["type"] == "message": + try: + data = json.loads(message["data"]) + await callback(data) + except json.JSONDecodeError: + logger.warning(f"Invalid JSON in notification: {message['data']}") + except Exception as e: + logger.error(f"Error processing notification: {e}") + except Exception as e: + if self._running: + logger.error(f"Error in notification listener: {e}") + + @property + def is_running(self) -> bool: + return self._running diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 3be52c5..b9460a8 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1,9 +1,95 @@ +import json import uuid import re -from typing import List, Optional +import asyncio +import logging +import threading +from datetime import datetime +from typing import List, Optional, Dict, Set from sqlalchemy.orm import Session +from sqlalchemy import event from app.models import User, Notification, Task, Comment, Mention +from app.core.redis_pubsub import publish_notification as redis_publish, get_channel_name +from app.core.redis import get_redis_sync + +logger = logging.getLogger(__name__) + +# Thread-safe lock for module-level state +_lock = threading.Lock() +# Module-level queue for notifications pending publish after commit +_pending_publish: Dict[int, List[dict]] = {} +# Track which sessions have handlers registered +_registered_sessions: Set[int] = set() + + +def _sync_publish(user_id: str, data: dict): + """Sync fallback to publish notification via Redis when no event loop available.""" + try: + redis_client = get_redis_sync() + channel = get_channel_name(user_id) + message = json.dumps(data, default=str) + redis_client.publish(channel, message) + logger.debug(f"Sync published notification to channel {channel}") + except Exception as e: + logger.error(f"Failed to sync publish notification to Redis: {e}") + + +def _cleanup_session(session_id: int, remove_registration: bool = True): + """Clean up session state after commit/rollback. Thread-safe. + + Args: + session_id: The session ID to clean up + remove_registration: If True, also remove from _registered_sessions. + Set to False for soft_rollback to avoid handler stacking. + """ + with _lock: + if remove_registration: + _registered_sessions.discard(session_id) + return _pending_publish.pop(session_id, []) + + +def _register_session_handlers(db: Session, session_id: int): + """Register after_commit, after_rollback, and after_soft_rollback handlers for a session.""" + with _lock: + if session_id in _registered_sessions: + return + _registered_sessions.add(session_id) + + @event.listens_for(db, "after_commit", once=True) + def _after_commit(session): + notifications = _cleanup_session(session_id) + if notifications: + try: + loop = asyncio.get_running_loop() + for n in notifications: + loop.create_task(_async_publish(n["user_id"], n["data"])) + except RuntimeError: + # No running event loop - use sync fallback + logger.info(f"No event loop, using sync publish for {len(notifications)} notification(s)") + for n in notifications: + _sync_publish(n["user_id"], n["data"]) + + @event.listens_for(db, "after_rollback", once=True) + def _after_rollback(session): + cleared = _cleanup_session(session_id) + if cleared: + logger.debug(f"Cleared {len(cleared)} pending notification(s) after rollback") + + @event.listens_for(db, "after_soft_rollback", once=True) + def _after_soft_rollback(session, previous_transaction): + # Only clear pending notifications, keep handler registration to avoid stacking + cleared = _cleanup_session(session_id, remove_registration=False) + if cleared: + logger.debug(f"Cleared {len(cleared)} pending notification(s) after soft rollback") + + +async def _async_publish(user_id: str, data: dict): + """Async helper to publish notification to Redis.""" + try: + await redis_publish(user_id, data) + except Exception as e: + logger.error(f"Failed to publish notification to Redis: {e}") class NotificationService: @@ -11,6 +97,56 @@ class NotificationService: MAX_MENTIONS_PER_COMMENT = 10 + @staticmethod + def notification_to_dict(notification: Notification) -> dict: + """Convert a Notification to a dict for publishing.""" + created_at = notification.created_at + if created_at is None: + created_at = datetime.utcnow() + return { + "id": notification.id, + "type": notification.type, + "reference_type": notification.reference_type, + "reference_id": notification.reference_id, + "title": notification.title, + "message": notification.message, + "is_read": notification.is_read, + "created_at": created_at.isoformat() if created_at else None, + } + + @staticmethod + async def publish_notifications(notifications: List[Notification]) -> None: + """Publish notifications to Redis for real-time WebSocket delivery.""" + for notification in notifications: + if notification and notification.user_id: + data = NotificationService.notification_to_dict(notification) + await redis_publish(notification.user_id, data) + + @staticmethod + async def publish_notification(notification: Optional[Notification]) -> None: + """Publish a single notification to Redis.""" + if notification: + await NotificationService.publish_notifications([notification]) + + @staticmethod + def _queue_for_publish(db: Session, notification: Notification): + """Queue notification for auto-publish after commit. Thread-safe.""" + session_id = id(db) + + # Register handlers first (has its own lock) + _register_session_handlers(db, session_id) + + # Store notification data (not object) for publishing + notification_data = { + "user_id": notification.user_id, + "data": NotificationService.notification_to_dict(notification), + } + + with _lock: + if session_id not in _pending_publish: + _pending_publish[session_id] = [] + _pending_publish[session_id].append(notification_data) + @staticmethod def create_notification( db: Session, @@ -21,7 +157,7 @@ class NotificationService: title: str, message: Optional[str] = None, ) -> Notification: - """Create a notification for a user.""" + """Create a notification for a user. Auto-publishes via Redis after commit.""" notification = Notification( id=str(uuid.uuid4()), user_id=user_id, @@ -32,6 +168,10 @@ class NotificationService: message=message, ) db.add(notification) + + # Queue for auto-publish after commit + NotificationService._queue_for_publish(db, notification) + return notification @staticmethod diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index 68277bd..64732a5 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from sqlalchemy import func from app.models import ( - User, Task, Project, ScheduledReport, ReportHistory + User, Task, Project, ScheduledReport, ReportHistory, Blocker ) from app.services.notification_service import NotificationService @@ -29,11 +29,15 @@ class ReportService: Get weekly task statistics for a user's projects. Returns stats for all projects where the user is the owner. + Includes: completed, in_progress, overdue, blocked, and next_week tasks. """ if week_start is None: week_start = ReportService.get_week_start() week_end = week_start + timedelta(days=7) + next_week_start = week_end + next_week_end = next_week_start + timedelta(days=7) + now = datetime.utcnow() # Get projects owned by the user projects = db.query(Project).filter(Project.owner_id == user_id).all() @@ -47,36 +51,71 @@ class ReportService: "completed_count": 0, "in_progress_count": 0, "overdue_count": 0, + "blocked_count": 0, + "next_week_count": 0, "total_tasks": 0, } } project_ids = [p.id for p in projects] - # Get all tasks for these projects + # Get all tasks for these projects with assignee info all_tasks = db.query(Task).filter(Task.project_id.in_(project_ids)).all() + # Get active blockers (unresolved) for these projects + active_blockers = db.query(Blocker).join(Task).filter( + Task.project_id.in_(project_ids), + Blocker.resolved_at.is_(None) + ).all() + + # Map task_id to blocker info + blocker_map: Dict[str, Blocker] = {b.task_id: b for b in active_blockers} + blocked_task_ids = set(blocker_map.keys()) + # Categorize tasks completed_tasks = [] in_progress_tasks = [] overdue_tasks = [] - - now = datetime.utcnow() + blocked_tasks = [] + next_week_tasks = [] for task in all_tasks: status_name = task.status.name.lower() if task.status else "" + is_done = status_name in ["done", "completed", "完成"] # Check if completed (updated this week) - if status_name in ["done", "completed", "完成"]: + if is_done: if task.updated_at and task.updated_at >= week_start: completed_tasks.append(task) - # Check if in progress - elif status_name in ["in progress", "進行中", "doing"]: - in_progress_tasks.append(task) + else: + # Check if in progress + if status_name in ["in progress", "進行中", "doing"]: + in_progress_tasks.append(task) - # Check if overdue - if task.due_date and task.due_date < now and status_name not in ["done", "completed", "完成"]: - overdue_tasks.append(task) + # Check if overdue + if task.due_date and task.due_date < now: + overdue_tasks.append(task) + + # Check if blocked + if task.id in blocked_task_ids: + blocked_tasks.append(task) + + # Check if due next week + if task.due_date and next_week_start <= task.due_date < next_week_end: + next_week_tasks.append(task) + + # Helper to get assignee name + def get_assignee_name(task: Task) -> Optional[str]: + if task.assignee: + return task.assignee.name + return None + + # Helper to calculate days overdue + def get_days_overdue(task: Task) -> int: + if task.due_date: + delta = now - task.due_date + return max(0, delta.days) + return 0 # Build project details project_details = [] @@ -85,6 +124,8 @@ class ReportService: project_completed = [t for t in completed_tasks if t.project_id == project.id] project_in_progress = [t for t in in_progress_tasks if t.project_id == project.id] project_overdue = [t for t in overdue_tasks if t.project_id == project.id] + project_blocked = [t for t in blocked_tasks if t.project_id == project.id] + project_next_week = [t for t in next_week_tasks if t.project_id == project.id] project_details.append({ "project_id": project.id, @@ -92,9 +133,57 @@ class ReportService: "completed_count": len(project_completed), "in_progress_count": len(project_in_progress), "overdue_count": len(project_overdue), + "blocked_count": len(project_blocked), + "next_week_count": len(project_next_week), "total_tasks": len(project_tasks), - "completed_tasks": [{"id": t.id, "title": t.title} for t in project_completed[:5]], - "overdue_tasks": [{"id": t.id, "title": t.title, "due_date": t.due_date.isoformat() if t.due_date else None} for t in project_overdue[:5]], + # Full task lists with detailed fields + "completed_tasks": [ + { + "id": t.id, + "title": t.title, + "completed_at": t.updated_at.isoformat() if t.updated_at else None, + "assignee_name": get_assignee_name(t), + } + for t in project_completed + ], + "in_progress_tasks": [ + { + "id": t.id, + "title": t.title, + "assignee_name": get_assignee_name(t), + "due_date": t.due_date.isoformat() if t.due_date else None, + } + for t in project_in_progress + ], + "overdue_tasks": [ + { + "id": t.id, + "title": t.title, + "due_date": t.due_date.isoformat() if t.due_date else None, + "days_overdue": get_days_overdue(t), + "assignee_name": get_assignee_name(t), + } + for t in project_overdue + ], + "blocked_tasks": [ + { + "id": t.id, + "title": t.title, + "blocker_reason": blocker_map[t.id].reason if t.id in blocker_map else None, + "blocked_since": blocker_map[t.id].created_at.isoformat() if t.id in blocker_map else None, + "assignee_name": get_assignee_name(t), + } + for t in project_blocked + ], + "next_week_tasks": [ + { + "id": t.id, + "title": t.title, + "due_date": t.due_date.isoformat() if t.due_date else None, + "assignee_name": get_assignee_name(t), + } + for t in project_next_week + ], }) return { @@ -106,6 +195,8 @@ class ReportService: "completed_count": len(completed_tasks), "in_progress_count": len(in_progress_tasks), "overdue_count": len(overdue_tasks), + "blocked_count": len(blocked_tasks), + "next_week_count": len(next_week_tasks), "total_tasks": len(all_tasks), } } @@ -161,10 +252,18 @@ class ReportService: completed = summary.get("completed_count", 0) in_progress = summary.get("in_progress_count", 0) overdue = summary.get("overdue_count", 0) + blocked = summary.get("blocked_count", 0) + next_week = summary.get("next_week_count", 0) message = f"本週完成 {completed} 項任務,進行中 {in_progress} 項" if overdue > 0: - message += f",逾期 {overdue} 項需關注" + message += f",逾期 {overdue} 項" + if blocked > 0: + message += f",阻礙 {blocked} 項" + if overdue > 0 or blocked > 0: + message += " 需關注" + if next_week > 0: + message += f"。下週預計 {next_week} 項" NotificationService.create_notification( db=db, diff --git a/backend/tests/test_reports.py b/backend/tests/test_reports.py index 2264336..9e17169 100644 --- a/backend/tests/test_reports.py +++ b/backend/tests/test_reports.py @@ -1,7 +1,7 @@ import pytest import uuid from datetime import datetime, timedelta -from app.models import User, Space, Project, Task, TaskStatus, ScheduledReport, ReportHistory +from app.models import User, Space, Project, Task, TaskStatus, ScheduledReport, ReportHistory, Blocker from app.services.report_service import ReportService @@ -258,3 +258,186 @@ class TestReportAPI: data = response.json() assert data["id"] == report.id assert "content" in data + + +class TestWeeklyReportContent: + """Tests for enhanced weekly report content (blocked/next_week tasks).""" + + def test_blocked_tasks_included(self, db, test_user, test_project, test_statuses): + """Test that blocked tasks are included in weekly stats.""" + # Create a task with blocker + blocked_task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title="Blocked Task", + status_id=test_statuses["in_progress"].id, + created_by=test_user.id, + assignee_id=test_user.id, + ) + db.add(blocked_task) + db.flush() + + # Create an unresolved blocker + blocker = Blocker( + id=str(uuid.uuid4()), + task_id=blocked_task.id, + reported_by=test_user.id, + reason="Waiting for external dependency", + ) + db.add(blocker) + db.commit() + + stats = ReportService.get_weekly_stats(db, test_user.id) + + assert stats["summary"]["blocked_count"] == 1 + assert len(stats["projects"]) == 1 + assert stats["projects"][0]["blocked_count"] == 1 + assert len(stats["projects"][0]["blocked_tasks"]) == 1 + assert stats["projects"][0]["blocked_tasks"][0]["title"] == "Blocked Task" + assert stats["projects"][0]["blocked_tasks"][0]["blocker_reason"] == "Waiting for external dependency" + + def test_resolved_blocker_not_included(self, db, test_user, test_project, test_statuses): + """Test that resolved blockers are not counted.""" + # Create a task with resolved blocker + task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title="Previously Blocked Task", + status_id=test_statuses["in_progress"].id, + created_by=test_user.id, + ) + db.add(task) + db.flush() + + # Create a resolved blocker + blocker = Blocker( + id=str(uuid.uuid4()), + task_id=task.id, + reported_by=test_user.id, + reason="Was blocked", + resolved_by=test_user.id, + resolved_at=datetime.utcnow(), + resolution_note="Fixed", + ) + db.add(blocker) + db.commit() + + stats = ReportService.get_weekly_stats(db, test_user.id) + + assert stats["summary"]["blocked_count"] == 0 + assert stats["projects"][0]["blocked_count"] == 0 + + def test_next_week_tasks_included(self, db, test_user, test_project, test_statuses): + """Test that next week tasks are included in weekly stats.""" + # Calculate next week dates + week_start = ReportService.get_week_start() + next_week_date = week_start + timedelta(days=10) # Next week + + # Create a task due next week + next_week_task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title="Next Week Task", + status_id=test_statuses["todo"].id, + due_date=next_week_date, + created_by=test_user.id, + assignee_id=test_user.id, + ) + db.add(next_week_task) + db.commit() + + stats = ReportService.get_weekly_stats(db, test_user.id) + + assert stats["summary"]["next_week_count"] == 1 + assert len(stats["projects"][0]["next_week_tasks"]) == 1 + assert stats["projects"][0]["next_week_tasks"][0]["title"] == "Next Week Task" + + def test_completed_task_not_in_next_week(self, db, test_user, test_project, test_statuses): + """Test that completed tasks are not included in next week list.""" + week_start = ReportService.get_week_start() + next_week_date = week_start + timedelta(days=10) + + # Create a completed task due next week + task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title="Done Next Week Task", + status_id=test_statuses["done"].id, + due_date=next_week_date, + created_by=test_user.id, + ) + db.add(task) + db.commit() + + stats = ReportService.get_weekly_stats(db, test_user.id) + + assert stats["summary"]["next_week_count"] == 0 + + def test_task_details_include_assignee_name(self, db, test_user, test_project, test_statuses): + """Test that task details include assignee name.""" + task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title="Assigned Task", + status_id=test_statuses["in_progress"].id, + created_by=test_user.id, + assignee_id=test_user.id, + ) + db.add(task) + db.commit() + + stats = ReportService.get_weekly_stats(db, test_user.id) + + assert len(stats["projects"][0]["in_progress_tasks"]) == 1 + assert stats["projects"][0]["in_progress_tasks"][0]["assignee_name"] == "Report User" + + def test_overdue_days_calculated(self, db, test_user, test_project, test_statuses): + """Test that days_overdue is correctly calculated.""" + # Create task overdue by 5 days + overdue_task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title="5 Days Overdue", + status_id=test_statuses["todo"].id, + due_date=datetime.utcnow() - timedelta(days=5), + created_by=test_user.id, + ) + db.add(overdue_task) + db.commit() + + stats = ReportService.get_weekly_stats(db, test_user.id) + + assert len(stats["projects"][0]["overdue_tasks"]) == 1 + assert stats["projects"][0]["overdue_tasks"][0]["days_overdue"] >= 5 + + def test_full_task_lists_no_limit(self, db, test_user, test_project, test_statuses): + """Test that task lists have no 5-item limit.""" + # Create 10 completed tasks + for i in range(10): + task = Task( + id=str(uuid.uuid4()), + project_id=test_project.id, + title=f"Completed Task {i}", + status_id=test_statuses["done"].id, + created_by=test_user.id, + ) + task.updated_at = datetime.utcnow() + db.add(task) + db.commit() + + stats = ReportService.get_weekly_stats(db, test_user.id) + + assert stats["summary"]["completed_count"] == 10 + assert len(stats["projects"][0]["completed_tasks"]) == 10 # No limit + + def test_summary_includes_all_counts(self, db, test_user, test_project, test_statuses): + """Test that summary includes all new count fields.""" + stats = ReportService.get_weekly_stats(db, test_user.id) + + summary = stats["summary"] + assert "completed_count" in summary + assert "in_progress_count" in summary + assert "overdue_count" in summary + assert "blocked_count" in summary + assert "next_week_count" in summary + assert "total_tasks" in summary diff --git a/frontend/src/components/WeeklyReportPreview.tsx b/frontend/src/components/WeeklyReportPreview.tsx index 7b47d5c..05e4263 100644 --- a/frontend/src/components/WeeklyReportPreview.tsx +++ b/frontend/src/components/WeeklyReportPreview.tsx @@ -1,5 +1,145 @@ import { useState, useEffect } from 'react' -import { reportsApi, WeeklyReportContent } from '../services/reports' +import { reportsApi, WeeklyReportContent, ProjectSummary } from '../services/reports' + +interface CollapsibleSectionProps { + title: string + count: number + colorClass: string + defaultOpen?: boolean + children: React.ReactNode +} + +function CollapsibleSection({ title, count, colorClass, defaultOpen = false, children }: CollapsibleSectionProps) { + const [isOpen, setIsOpen] = useState(defaultOpen) + + if (count === 0) return null + + return ( +
+ + {isOpen &&
{children}
} +
+ ) +} + +interface TaskItemProps { + title: string + subtitle?: string + highlight?: 'overdue' | 'blocked' +} + +function TaskItem({ title, subtitle, highlight }: TaskItemProps) { + const bgClass = highlight === 'overdue' + ? 'bg-red-50 border-l-2 border-red-400' + : highlight === 'blocked' + ? 'bg-orange-50 border-l-2 border-orange-400' + : 'bg-gray-50' + + return ( +
+
{title}
+ {subtitle &&
{subtitle}
} +
+ ) +} + +function ProjectCard({ project }: { project: ProjectSummary }) { + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '' + return new Date(dateStr).toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' }) + } + + return ( +
+
+
{project.project_title}
+ + {project.completed_count}/{project.total_tasks} completed + +
+ + {/* Summary row */} +
+ {project.completed_count} done + {project.in_progress_count} in progress + {project.overdue_count > 0 && ( + {project.overdue_count} overdue + )} + {project.blocked_count > 0 && ( + {project.blocked_count} blocked + )} + {project.next_week_count > 0 && ( + {project.next_week_count} next week + )} +
+ + {/* Completed Tasks */} + + {project.completed_tasks.map(task => ( + + ))} + + + {/* In Progress Tasks */} + + {project.in_progress_tasks.map(task => ( + + ))} + + + {/* Overdue Tasks */} + + {project.overdue_tasks.map(task => ( + + ))} + + + {/* Blocked Tasks */} + + {project.blocked_tasks.map(task => ( + + ))} + + + {/* Next Week Tasks */} + + {project.next_week_tasks.map(task => ( + + ))} + +
+ ) +} export function WeeklyReportPreview() { const [report, setReport] = useState(null) @@ -82,22 +222,30 @@ export function WeeklyReportPreview() { {/* Summary Cards */} -
-
-

{report.summary.completed_count}

-

Completed

+
+
+

{report.summary.completed_count}

+

Completed

-
-

{report.summary.in_progress_count}

-

In Progress

+
+

{report.summary.in_progress_count}

+

In Progress

-
-

{report.summary.overdue_count}

-

Overdue

+
+

{report.summary.overdue_count}

+

Overdue

-
-

{report.summary.total_tasks}

-

Total Tasks

+
+

{report.summary.blocked_count}

+

Blocked

+
+
+

{report.summary.next_week_count}

+

Next Week

+
+
+

{report.summary.total_tasks}

+

Total

@@ -106,21 +254,7 @@ export function WeeklyReportPreview() {

Projects

{report.projects.map(project => ( -
-
-
{project.project_title}
- - {project.completed_count}/{project.total_tasks} completed - -
-
- {project.completed_count} done - {project.in_progress_count} in progress - {project.overdue_count > 0 && ( - {project.overdue_count} overdue - )} -
-
+ ))}
) : ( diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx index 0dd1aa2..ee2d2b5 100644 --- a/frontend/src/contexts/NotificationContext.tsx +++ b/frontend/src/contexts/NotificationContext.tsx @@ -23,8 +23,8 @@ export function NotificationProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const wsRef = useRef(null) - const pingIntervalRef = useRef(null) - const reconnectTimeoutRef = useRef(null) + const pingIntervalRef = useRef | null>(null) + const reconnectTimeoutRef = useRef | null>(null) const refreshUnreadCount = useCallback(async () => { try { @@ -79,8 +79,15 @@ export function NotificationProvider({ children }: { children: ReactNode }) { 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}` + // Use env var if available, otherwise derive from current location + let wsUrl: string + const envWsUrl = import.meta.env.VITE_WS_URL + if (envWsUrl) { + wsUrl = `${envWsUrl}/ws/notifications?token=${token}` + } else { + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + wsUrl = `${wsProtocol}//${window.location.host}/ws/notifications?token=${token}` + } try { const ws = new WebSocket(wsUrl) @@ -101,6 +108,32 @@ export function NotificationProvider({ children }: { children: ReactNode }) { const message = JSON.parse(event.data) switch (message.type) { + case 'connected': + console.log('WebSocket authenticated:', message.data.message) + break + + case 'unread_sync': + // Merge unread notifications without removing already-loaded notifications + setNotifications(prev => { + const unreadNotifications = message.data.notifications || [] + const existingIds = new Set(prev.map(n => n.id)) + + // Add new unread notifications that don't exist in current list + const newNotifications = unreadNotifications.filter( + (n: Notification) => !existingIds.has(n.id) + ) + + // Update existing unread notifications and prepend new ones + const updated = prev.map(existing => { + const fromSync = unreadNotifications.find((n: Notification) => n.id === existing.id) + return fromSync || existing + }) + + return [...newNotifications, ...updated] + }) + setUnreadCount(message.data.unread_count || 0) + break + case 'notification': // Add new notification to the top setNotifications(prev => [message.data, ...prev]) @@ -111,6 +144,13 @@ export function NotificationProvider({ children }: { children: ReactNode }) { setUnreadCount(message.data.unread_count) break + case 'ping': + // Server ping - respond with pong + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'pong' })) + } + break + case 'pong': // Pong received, connection is alive break diff --git a/frontend/src/services/reports.ts b/frontend/src/services/reports.ts index bd5e27c..1e18f15 100644 --- a/frontend/src/services/reports.ts +++ b/frontend/src/services/reports.ts @@ -4,18 +4,62 @@ export interface ReportSummary { completed_count: number in_progress_count: number overdue_count: number + blocked_count: number + next_week_count: number total_tasks: number } +export interface CompletedTask { + id: string + title: string + completed_at: string | null + assignee_name: string | null +} + +export interface InProgressTask { + id: string + title: string + assignee_name: string | null + due_date: string | null +} + +export interface OverdueTask { + id: string + title: string + due_date: string | null + days_overdue: number + assignee_name: string | null +} + +export interface BlockedTask { + id: string + title: string + blocker_reason: string | null + blocked_since: string | null + assignee_name: string | null +} + +export interface NextWeekTask { + id: string + title: string + due_date: string | null + assignee_name: string | null +} + export interface ProjectSummary { project_id: string project_title: string completed_count: number in_progress_count: number overdue_count: number + blocked_count: number + next_week_count: number total_tasks: number - completed_tasks: Array<{ id: string; title: string }> - overdue_tasks: Array<{ id: string; title: string; due_date: string | null }> + completed_tasks: CompletedTask[] + in_progress_tasks: InProgressTask[] + overdue_tasks: OverdueTask[] + blocked_tasks: BlockedTask[] + next_week_tasks: NextWeekTask[] } export interface WeeklyReportContent { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 91a0315..61380d6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,6 +10,11 @@ export default defineConfig({ target: 'http://localhost:8000', changeOrigin: true, }, + '/ws': { + target: 'ws://localhost:8000', + ws: true, + changeOrigin: true, + }, }, }, }) diff --git a/openspec/changes/fix-realtime-notifications/design.md b/openspec/changes/archive/2025-12-30-fix-realtime-notifications/design.md similarity index 100% rename from openspec/changes/fix-realtime-notifications/design.md rename to openspec/changes/archive/2025-12-30-fix-realtime-notifications/design.md diff --git a/openspec/changes/fix-realtime-notifications/proposal.md b/openspec/changes/archive/2025-12-30-fix-realtime-notifications/proposal.md similarity index 100% rename from openspec/changes/fix-realtime-notifications/proposal.md rename to openspec/changes/archive/2025-12-30-fix-realtime-notifications/proposal.md diff --git a/openspec/changes/fix-realtime-notifications/specs/collaboration/spec.md b/openspec/changes/archive/2025-12-30-fix-realtime-notifications/specs/collaboration/spec.md similarity index 100% rename from openspec/changes/fix-realtime-notifications/specs/collaboration/spec.md rename to openspec/changes/archive/2025-12-30-fix-realtime-notifications/specs/collaboration/spec.md diff --git a/openspec/changes/fix-realtime-notifications/tasks.md b/openspec/changes/archive/2025-12-30-fix-realtime-notifications/tasks.md similarity index 100% rename from openspec/changes/fix-realtime-notifications/tasks.md rename to openspec/changes/archive/2025-12-30-fix-realtime-notifications/tasks.md diff --git a/openspec/changes/fix-weekly-report/design.md b/openspec/changes/archive/2025-12-30-fix-weekly-report/design.md similarity index 100% rename from openspec/changes/fix-weekly-report/design.md rename to openspec/changes/archive/2025-12-30-fix-weekly-report/design.md diff --git a/openspec/changes/fix-weekly-report/proposal.md b/openspec/changes/archive/2025-12-30-fix-weekly-report/proposal.md similarity index 100% rename from openspec/changes/fix-weekly-report/proposal.md rename to openspec/changes/archive/2025-12-30-fix-weekly-report/proposal.md diff --git a/openspec/changes/fix-weekly-report/specs/automation/spec.md b/openspec/changes/archive/2025-12-30-fix-weekly-report/specs/automation/spec.md similarity index 100% rename from openspec/changes/fix-weekly-report/specs/automation/spec.md rename to openspec/changes/archive/2025-12-30-fix-weekly-report/specs/automation/spec.md diff --git a/openspec/changes/archive/2025-12-30-fix-weekly-report/tasks.md b/openspec/changes/archive/2025-12-30-fix-weekly-report/tasks.md new file mode 100644 index 0000000..267d73a --- /dev/null +++ b/openspec/changes/archive/2025-12-30-fix-weekly-report/tasks.md @@ -0,0 +1,38 @@ +## Phase 1: Backend Report Enhancement + +### 1.1 ReportService 擴充 +- [x] 1.1.1 移除 completed_tasks/overdue_tasks 的 5 筆限制 +- [x] 1.1.2 新增 in_progress_tasks 完整清單 +- [x] 1.1.3 新增 blocked_tasks 查詢與清單 +- [x] 1.1.4 新增 next_week_tasks 查詢與清單 +- [x] 1.1.5 擴充 summary 包含 blocked_count 與 next_week_count + +### 1.2 任務明細欄位 +- [x] 1.2.1 completed_tasks 加入 completed_at, assignee_name +- [x] 1.2.2 in_progress_tasks 加入 assignee_name, due_date +- [x] 1.2.3 overdue_tasks 加入 days_overdue 計算 +- [x] 1.2.4 blocked_tasks 加入 blocker_reason, blocked_since +- [x] 1.2.5 next_week_tasks 加入 due_date, assignee_name + +### 1.3 Testing - Phase 1 +- [x] 1.3.1 週報內容結構測試 +- [x] 1.3.2 阻礙任務查詢測試 +- [x] 1.3.3 下週預計任務測試 + +## Phase 2: Frontend Display + +### 2.1 WeeklyReportPreview 更新 +- [x] 2.1.1 新增 BlockedTasksSection 元件 +- [x] 2.1.2 新增 NextWeekTasksSection 元件 +- [x] 2.1.3 更新 CompletedTasksSection 顯示完整清單 +- [x] 2.1.4 更新 InProgressTasksSection 顯示完整清單 +- [x] 2.1.5 更新 OverdueTasksSection 顯示 days_overdue + +### 2.2 UI 改善 +- [x] 2.2.1 可摺疊區塊設計 +- [x] 2.2.2 任務項目樣式統一 +- [x] 2.2.3 逾期/阻礙 highlight 樣式 + +### 2.3 Testing - Phase 2 +- [x] 2.3.1 前端週報顯示測試 +- [x] 2.3.2 空清單狀態測試 diff --git a/openspec/changes/fix-weekly-report/tasks.md b/openspec/changes/fix-weekly-report/tasks.md deleted file mode 100644 index 988e0f3..0000000 --- a/openspec/changes/fix-weekly-report/tasks.md +++ /dev/null @@ -1,38 +0,0 @@ -## Phase 1: Backend Report Enhancement - -### 1.1 ReportService 擴充 -- [ ] 1.1.1 移除 completed_tasks/overdue_tasks 的 5 筆限制 -- [ ] 1.1.2 新增 in_progress_tasks 完整清單 -- [ ] 1.1.3 新增 blocked_tasks 查詢與清單 -- [ ] 1.1.4 新增 next_week_tasks 查詢與清單 -- [ ] 1.1.5 擴充 summary 包含 blocked_count 與 next_week_count - -### 1.2 任務明細欄位 -- [ ] 1.2.1 completed_tasks 加入 completed_at, assignee_name -- [ ] 1.2.2 in_progress_tasks 加入 assignee_name, due_date -- [ ] 1.2.3 overdue_tasks 加入 days_overdue 計算 -- [ ] 1.2.4 blocked_tasks 加入 blocker_reason, blocked_since -- [ ] 1.2.5 next_week_tasks 加入 due_date, assignee_name - -### 1.3 Testing - Phase 1 -- [ ] 1.3.1 週報內容結構測試 -- [ ] 1.3.2 阻礙任務查詢測試 -- [ ] 1.3.3 下週預計任務測試 - -## Phase 2: Frontend Display - -### 2.1 WeeklyReportPreview 更新 -- [ ] 2.1.1 新增 BlockedTasksSection 元件 -- [ ] 2.1.2 新增 NextWeekTasksSection 元件 -- [ ] 2.1.3 更新 CompletedTasksSection 顯示完整清單 -- [ ] 2.1.4 更新 InProgressTasksSection 顯示完整清單 -- [ ] 2.1.5 更新 OverdueTasksSection 顯示 days_overdue - -### 2.2 UI 改善 -- [ ] 2.2.1 可摺疊區塊設計 -- [ ] 2.2.2 任務項目樣式統一 -- [ ] 2.2.3 逾期/阻礙 highlight 樣式 - -### 2.3 Testing - Phase 2 -- [ ] 2.3.1 前端週報顯示測試 -- [ ] 2.3.2 空清單狀態測試 diff --git a/openspec/specs/audit-trail/spec.md b/openspec/specs/audit-trail/spec.md index 9501cd7..99aebd1 100644 --- a/openspec/specs/audit-trail/spec.md +++ b/openspec/specs/audit-trail/spec.md @@ -1,5 +1,7 @@ # Audit Trail +## Purpose + 系統級稽核追蹤,記錄所有關鍵變更操作供合規與追溯需求。 ## Requirements diff --git a/openspec/specs/automation/spec.md b/openspec/specs/automation/spec.md index 16bb370..56500fa 100644 --- a/openspec/specs/automation/spec.md +++ b/openspec/specs/automation/spec.md @@ -1,9 +1,9 @@ # Automation +## Purpose + 自動化系統,提供觸發器與自動報告生成功能。 - ## Requirements - ### Requirement: Trigger-Based Automation 系統 SHALL 支援觸發器 (Triggers),當特定條件滿足時自動執行動作。 @@ -61,41 +61,30 @@ - **THEN** 系統自動將任務指派給指定人員 ### Requirement: Automated Weekly Report -系統 SHALL 每週五下午 4:00 自動彙整本週「已完成」與「進行中」的任務發送給主管。 +系統 SHALL 每週五下午 4:00 自動彙整完整任務清單發送給主管。 -#### Scenario: 週報自動生成 -- **GIVEN** 系統排程設定為每週五 16:00 -- **WHEN** 到達排程時間 -- **THEN** 系統彙整每位主管所屬專案的任務狀態 -- **AND** 生成週報並發送給該主管 - -#### Scenario: 週報內容 +#### Scenario: 週報內容完整清單 - **GIVEN** 週報生成中 - **WHEN** 系統彙整資料 -- **THEN** 週報包含: - - 本週已完成任務清單 - - 進行中任務清單 - - 逾期任務警示 - - 阻礙中任務清單 - - 下週預計完成任務 +- **THEN** 週報包含各專案的: + - 本週已完成任務清單(含 completed_at, assignee_name) + - 進行中任務清單(含 assignee_name, due_date) + - 逾期任務警示(含 due_date, days_overdue) + - 阻礙中任務清單(含 blocker_reason, blocked_since) + - 下週預計完成任務(含 due_date, assignee_name) +- **AND** 不設任務數量上限 -#### Scenario: 週報發送方式 -- **GIVEN** 週報已生成 -- **WHEN** 系統發送週報 -- **THEN** 透過系統內通知發送給收件者 -- **AND** 週報可在系統內查閱歷史紀錄 +#### Scenario: 阻礙任務識別 +- **GIVEN** 任務有未解除的 Blocker 記錄 +- **WHEN** 週報查詢阻礙任務 +- **THEN** 系統查詢 Blocker 表 resolved_at IS NULL 的任務 +- **AND** 顯示阻礙原因與開始時間 -#### Scenario: Email 發送(可選) -- **GIVEN** 週報已生成且系統已啟用 Email 功能 -- **WHEN** 系統發送週報 -- **THEN** 同時透過 Email 發送給收件者 -- **AND** 記錄 Email 發送狀態 - -#### Scenario: Email 未啟用 -- **GIVEN** 週報已生成但系統未啟用 Email 功能 -- **WHEN** 系統發送週報 -- **THEN** 僅透過系統內通知發送 -- **AND** 記錄「Email 未啟用,僅發送系統通知」 +#### Scenario: 下週預計任務 +- **GIVEN** 任務的 due_date 在下週範圍內 +- **WHEN** 週報查詢下週預計任務 +- **THEN** 系統篩選 due_date >= 下週一 且 < 下週日 +- **AND** 排除已完成狀態的任務 ## Data Model diff --git a/openspec/specs/collaboration/spec.md b/openspec/specs/collaboration/spec.md index 99369fe..32c3e41 100644 --- a/openspec/specs/collaboration/spec.md +++ b/openspec/specs/collaboration/spec.md @@ -1,5 +1,7 @@ # Collaboration +## Purpose + 協作功能系統,提供任務內討論、@提及通知與阻礙處理機制。 ## Requirements diff --git a/openspec/specs/document-management/spec.md b/openspec/specs/document-management/spec.md index 3cbc6b3..669bdae 100644 --- a/openspec/specs/document-management/spec.md +++ b/openspec/specs/document-management/spec.md @@ -1,5 +1,7 @@ # Document Management +## Purpose + 文件管理系統,提供檔案附件、版本控制、加密存儲與浮水印功能。 ## Requirements diff --git a/openspec/specs/task-management/spec.md b/openspec/specs/task-management/spec.md index dbee761..f6f51ea 100644 --- a/openspec/specs/task-management/spec.md +++ b/openspec/specs/task-management/spec.md @@ -1,5 +1,7 @@ # Task Management +## Purpose + 任務管理核心系統,支援多層級架構、自定義欄位與多維視角。 ## Requirements