Phase 5: Notifications — WebSocket, APScheduler, AI tool, health review

Backend:
- Notification model + Alembic migration
- Notification service: CRUD, mark read, unread count, pending scheduled
- WebSocket manager singleton for real-time push
- WebSocket endpoint /ws/notifications with JWT auth via query param
- APScheduler integration: periodic notification sender (every 60s),
  daily proactive health review job (8 AM)
- AI tool: schedule_notification (immediate or scheduled)
- Health review worker: analyzes user memory via Claude, creates
  ai_generated notifications with WebSocket push

Frontend:
- Notification API client + Zustand store
- WebSocket hook with auto-reconnect (exponential backoff)
- Notification bell in header with unread count badge + dropdown
- Notifications page with type badges, mark read, mark all read
- WebSocket initialized in AppLayout for app-wide real-time updates
- Enabled notifications nav in sidebar
- English + Russian translations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 13:57:25 +03:00
parent 8b8fe916f0
commit ada7e82961
30 changed files with 1074 additions and 4 deletions

View File

@@ -0,0 +1,102 @@
import uuid
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
async def create_notification(
db: AsyncSession,
user_id: uuid.UUID,
title: str,
body: str,
type: str = "info",
channel: str = "in_app",
scheduled_at: datetime | None = None,
metadata: dict | None = None,
) -> Notification:
notif = Notification(
user_id=user_id,
title=title,
body=body,
type=type,
channel=channel,
status="pending" if scheduled_at else "sent",
scheduled_at=scheduled_at,
sent_at=None if scheduled_at else datetime.now(timezone.utc),
metadata_=metadata,
)
db.add(notif)
await db.flush()
return notif
async def get_user_notifications(
db: AsyncSession, user_id: uuid.UUID,
status_filter: str | None = None, limit: int = 50, offset: int = 0,
) -> list[Notification]:
stmt = select(Notification).where(Notification.user_id == user_id)
if status_filter:
stmt = stmt.where(Notification.status == status_filter)
stmt = stmt.order_by(Notification.created_at.desc()).limit(limit).offset(offset)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_unread_count(db: AsyncSession, user_id: uuid.UUID) -> int:
result = await db.scalar(
select(func.count()).select_from(Notification).where(
Notification.user_id == user_id,
Notification.status.in_(["sent", "delivered"]),
Notification.read_at.is_(None),
)
)
return result or 0
async def mark_as_read(db: AsyncSession, notification_id: uuid.UUID, user_id: uuid.UUID) -> Notification:
result = await db.execute(
select(Notification).where(Notification.id == notification_id, Notification.user_id == user_id)
)
notif = result.scalar_one_or_none()
if not notif:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
notif.status = "read"
notif.read_at = datetime.now(timezone.utc)
await db.flush()
return notif
async def mark_all_read(db: AsyncSession, user_id: uuid.UUID) -> int:
result = await db.execute(
update(Notification)
.where(
Notification.user_id == user_id,
Notification.read_at.is_(None),
)
.values(status="read", read_at=datetime.now(timezone.utc))
)
return result.rowcount
async def get_pending_scheduled(db: AsyncSession) -> list[Notification]:
now = datetime.now(timezone.utc)
result = await db.execute(
select(Notification).where(
Notification.status == "pending",
Notification.scheduled_at <= now,
)
)
return list(result.scalars().all())
async def mark_as_sent(db: AsyncSession, notification_id: uuid.UUID) -> None:
result = await db.execute(select(Notification).where(Notification.id == notification_id))
notif = result.scalar_one_or_none()
if notif:
notif.status = "sent"
notif.sent_at = datetime.now(timezone.utc)
await db.flush()