Уведомления: 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
@@ -0,0 +1,190 @@
"""Персистентные уведомления игрокам (приглашения, старт/финиш партии, титулы).
Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу
подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере.
Хранение — 72 часа; протухшие удаляются при чтении списка и фоновой задачей.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
from app.models import Group, Match, MatchParticipant, Notification
from app.services import notify
RETENTION_HOURS = 72
def _now() -> datetime:
return datetime.now(timezone.utc)
def _cutoff() -> datetime:
# Наивный UTC — сопоставимо с тем, как SQLite отдаёт сохранённые datetime.
return _now().replace(tzinfo=None) - timedelta(hours=RETENTION_HOURS)
# ─── Базовые операции ─────────────────────────────────────────────────────────
def create_for(
session: Session,
user_ids,
*,
type: str,
title: str,
body: str | None = None,
link: str | None = None,
) -> list[Notification]:
"""Создать одно уведомление каждому из user_ids (один commit) и пушнуть им SSE-сигнал."""
ids = [uid for uid in dict.fromkeys(user_ids) if uid is not None]
if not ids:
return []
rows = [
Notification(user_id=uid, type=type, title=title, body=body, link=link) for uid in ids
]
session.add_all(rows)
session.commit()
for uid in ids:
notify.notifications_changed(uid)
return rows
def create(
session: Session,
user_id: int,
*,
type: str,
title: str,
body: str | None = None,
link: str | None = None,
) -> Notification | None:
rows = create_for(session, [user_id], type=type, title=title, body=body, link=link)
return rows[0] if rows else None
def purge_expired(session: Session) -> int:
"""Удалить уведомления старше RETENTION_HOURS. Возвращает число удалённых."""
rows = session.exec(
select(Notification).where(Notification.created_at < _cutoff())
).all()
for row in rows:
session.delete(row)
if rows:
session.commit()
return len(rows)
def list_for_user(session: Session, user_id: int) -> dict:
"""Свежие (<72ч) уведомления пользователя + число непрочитанных. Чистит протухшие."""
purge_expired(session)
rows = session.exec(
select(Notification)
.where(Notification.user_id == user_id)
.order_by(Notification.created_at.desc())
).all()
items = [
{
"id": n.id,
"type": n.type,
"title": n.title,
"body": n.body,
"link": n.link,
"read_at": iso_utc(n.read_at),
"created_at": iso_utc(n.created_at),
}
for n in rows
]
unread = sum(1 for n in rows if n.read_at is None)
return {"items": items, "unread_count": unread}
def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> int:
"""Отметить прочитанными все непрочитанные пользователя (или указанные по id)."""
query = select(Notification).where(
Notification.user_id == user_id, Notification.read_at.is_(None)
)
if ids:
query = query.where(Notification.id.in_(ids))
rows = session.exec(query).all()
now = _now()
for row in rows:
row.read_at = now
session.add(row)
if rows:
session.commit()
return len(rows)
# ─── Типовые хелперы под триггеры ──────────────────────────────────────────────
def _match_participant_ids(session: Session, match_id: int) -> list[int]:
return list(
session.exec(
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
).all()
)
def _group_name(session: Session, group_id: int) -> str | None:
group = session.get(Group, group_id)
return group.name if group else None
def invited_to_group(
session: Session, user_id: int, group_name: str, inviter_nickname: str | None = None
) -> None:
create(
session,
user_id,
type="invite",
title=f"Приглашение в группу «{group_name}»",
body=f"пригласил: {inviter_nickname}" if inviter_nickname else None,
link="/group",
)
def match_started(session: Session, match: Match, actor_id: int) -> None:
ids = [uid for uid in _match_participant_ids(session, match.id) if uid != actor_id] # type: ignore[arg-type]
name = _group_name(session, match.group_id)
create_for(
session,
ids,
type="match_started",
title="Началась партия",
body=f"Группа «{name}»" if name else None,
link=f"/match/{match.id}",
)
def match_finished(session: Session, match: Match, actor_id: int) -> None:
ids = [uid for uid in _match_participant_ids(session, match.id) if uid != actor_id] # type: ignore[arg-type]
name = _group_name(session, match.group_id)
create_for(
session,
ids,
type="match_finished",
title="Партия завершена",
body=f"Группа «{name}»" if name else None,
link=f"/match/{match.id}",
)
def title_earned(session: Session, user_id: int, achievement_slug: str) -> None:
"""ЗАДЕЛ: уведомление о полученном титуле. Пока нигде не вызывается — заработает,
когда появится механизм выдачи титулов игрокам."""
name = achievement_slug
try:
from app.services import achievement_service
name = achievement_service.get(achievement_slug).get("name", achievement_slug)
except Exception: # noqa: BLE001
pass
create(
session,
user_id,
type="title",
title=f"Вы получили титул: {name}",
link="/account",
)