Files
personal-ai-assistant/backend/app/services/notification_service.py
dolgolyov.alexei ada7e82961 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>
2026-03-19 13:57:25 +03:00

103 lines
3.2 KiB
Python

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