feat: enhance weekly report and realtime notifications

Weekly Report (fix-weekly-report):
- Remove 5-task limit, show all tasks per category
- Add blocked tasks with blocker_reason and blocked_since
- Add next week tasks (due in coming week)
- Add assignee_name, completed_at, days_overdue to task details
- Frontend collapsible sections for each task category
- 8 new tests for enhanced report content

Realtime Notifications (fix-realtime-notifications):
- SQLAlchemy event-based notification publishing
- Redis Pub/Sub for multi-process broadcast
- Fix soft rollback handler stacking issue
- Fix ping scheduling drift (send immediately when interval expires)
- Frontend NotificationContext with WebSocket reconnection

Spec Fixes:
- Add missing ## Purpose sections to 5 specs

🤖 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-30 20:52:08 +08:00
parent 10db2c9d1f
commit 64874d5425
25 changed files with 1034 additions and 140 deletions

View File

@@ -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