Уведомления: persistent + live (push-to-invalidate → pull)

Новая система уведомлений поверх готовой SSE-шины: события пишутся в БД,
живут 72 часа и чистятся (при чтении списка + фоновой задачей), всплывают
сверху экрана в момент прихода, доступны через колокольчик в правом верхнем
углу (бейдж непрочитанных + панель).

Типы: приглашение в группу (→ /group), старт/финиш партии участникам кроме
инициатора (→ /match/{id}). Титулы — готовый хелпер-задел (не подключён, т.к.
выдача титулов игрокам ещё не реализована).

Бэкенд: модель Notification + миграция 0008 (идемпотентная), notification_service,
notify.notifications_changed, роутер /api/notifications (GET + /read), триггеры
в groups/matches, фоновая чистка в lifespan, защита hub.publish от закрытого loop.
Фронт: useNotifications/useMarkNotificationsRead, NotificationBell/Panel/Toaster,
перекомпоновка top-bar, стили; useServerEvents знает тип notifications.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 15:16:40 +03:00
co-authored by Claude Opus 4.8
parent ef5c242e16
commit f4286ffa6c
20 changed files with 939 additions and 13 deletions
+33 -2
View File
@@ -22,6 +22,7 @@ from app.routers import (
groups,
invitations,
matches,
notifications,
reference,
stats,
users,
@@ -71,6 +72,31 @@ class CSRFMiddleware:
await self.app(scope, receive, send)
_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим протухшие уведомления (>72ч)
async def _notifications_purge_loop() -> None:
"""Фоновая чистка протухших уведомлений (single-worker безопасно). Чтобы удалялись
«отовсюду» даже у неактивных пользователей (помимо очистки при чтении списка)."""
import asyncio
from app.db.session import Session, engine
from app.services import notification_service
def _purge_once() -> None:
with Session(engine) as session:
notification_service.purge_expired(session)
while True:
try:
await asyncio.sleep(_NOTIFICATIONS_PURGE_INTERVAL)
await asyncio.to_thread(_purge_once)
except asyncio.CancelledError:
break
except Exception as exc: # noqa: BLE001
logging.getLogger("fs").warning("Чистка уведомлений пропущена: %s", exc)
@asynccontextmanager
async def _lifespan(_app: FastAPI):
# SSE-шина публикует из sync-роутеров в этот event-loop — сохраняем ссылку (все окружения).
@@ -91,7 +117,12 @@ async def _lifespan(_app: FastAPI):
logging.getLogger("fs").warning(
"Стартовый bootstrap пропущен (примените миграции): %s", exc
)
yield
purge_task = asyncio.create_task(_notifications_purge_loop())
try:
yield
finally:
purge_task.cancel()
def create_app() -> FastAPI:
@@ -135,7 +166,7 @@ def create_app() -> FastAPI:
# API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, invitations.router,
matches.router, reference.router, stats.router, achievements.router,
events.router, admin.router]
events.router, notifications.router, admin.router]
for r in api_routers:
app.include_router(r, prefix="/api")