Уведомления: 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
+27
View File
@@ -336,6 +336,33 @@ class MatchAttachment(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
# ─── Уведомления ─────────────────────────────────────────────────────────────
class Notification(SQLModel, table=True):
"""Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.).
Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер —
фронт лишь отображает. Хранятся 72 часа; протухшие чистятся фоном и при чтении списка."""
__tablename__ = "notifications"
__table_args__ = (
Index("ix_notifications_user_created", "user_id", "created_at"),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
)
type: str = Field(sa_column=Column(String(32), nullable=False))
title: str = Field(sa_column=Column(String(255), nullable=False))
body: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False, index=True)
# ─── Журнал аудита ───────────────────────────────────────────────────────────
class AuditLog(SQLModel, table=True):