From f4286ffa6c140d2754a2f5a3c630ecbe99b8386b Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 26 Jun 2026 15:16:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F:=20persistent=20+=20live=20(push-to-in?= =?UTF-8?q?validate=20=E2=86=92=20pull)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая система уведомлений поверх готовой 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 --- .../alembic/versions/0008_notifications.py | 55 +++++ backend/app/core/events.py | 7 +- backend/app/main.py | 35 +++- backend/app/models.py | 27 +++ backend/app/routers/groups.py | 4 + backend/app/routers/matches.py | 3 + backend/app/routers/notifications.py | 34 ++++ backend/app/schemas/api.py | 21 ++ backend/app/services/notification_service.py | 190 ++++++++++++++++++ backend/app/services/notify.py | 5 + backend/tests/test_notifications.py | 158 +++++++++++++++ frontend/src/api/queryKeys.ts | 1 + frontend/src/api/schema.d.ts | 116 +++++++++++ frontend/src/components/AppShell.tsx | 25 ++- frontend/src/components/NotificationBell.tsx | 42 ++++ frontend/src/components/NotificationPanel.tsx | 43 ++++ .../src/components/NotificationToaster.tsx | 50 +++++ frontend/src/hooks/notifications.ts | 25 +++ frontend/src/hooks/useServerEvents.ts | 4 +- frontend/src/styles/global.css | 107 ++++++++++ 20 files changed, 939 insertions(+), 13 deletions(-) create mode 100644 backend/alembic/versions/0008_notifications.py create mode 100644 backend/app/routers/notifications.py create mode 100644 backend/app/services/notification_service.py create mode 100644 backend/tests/test_notifications.py create mode 100644 frontend/src/components/NotificationBell.tsx create mode 100644 frontend/src/components/NotificationPanel.tsx create mode 100644 frontend/src/components/NotificationToaster.tsx create mode 100644 frontend/src/hooks/notifications.ts diff --git a/backend/alembic/versions/0008_notifications.py b/backend/alembic/versions/0008_notifications.py new file mode 100644 index 0000000..42055d1 --- /dev/null +++ b/backend/alembic/versions/0008_notifications.py @@ -0,0 +1,55 @@ +"""Уведомления: таблица notifications (персистентные уведомления игроку). + +Идемпотентна (как 0006/0007): на свежей БД таблицу создаёт 0001 (create_all); на существующей — +create_table здесь. + +Revision ID: 0008_notifications +Revises: 0007_match_attachments +Create Date: 2026-06-26 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0008_notifications" +down_revision: Union[str, None] = "0007_match_attachments" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + if "notifications" in insp.get_table_names(): + return + op.create_table( + "notifications", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("type", sa.String(32), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("body", sa.Text(), nullable=True), + sa.Column("link", sa.String(255), nullable=True), + sa.Column("read_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_notifications_user_id", "notifications", ["user_id"]) + op.create_index("ix_notifications_created_at", "notifications", ["created_at"]) + op.create_index( + "ix_notifications_user_created", "notifications", ["user_id", "created_at"] + ) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + if "notifications" in insp.get_table_names(): + op.drop_table("notifications") diff --git a/backend/app/core/events.py b/backend/app/core/events.py index e05f149..54960fc 100644 --- a/backend/app/core/events.py +++ b/backend/app/core/events.py @@ -37,11 +37,14 @@ class EventHub: def publish(self, user_ids: Iterable[int], event: dict[str, Any]) -> None: """Доставить событие подписчикам с указанными user_id (потокобезопасно).""" loop = self._loop - if loop is None: + if loop is None or loop.is_closed(): return ids = [uid for uid in user_ids if uid is not None] if ids: - loop.call_soon_threadsafe(self._fanout, ids, event) + try: + loop.call_soon_threadsafe(self._fanout, ids, event) + except RuntimeError: + pass # loop закрылся между проверкой и вызовом (напр. при shutdown) def _fanout(self, ids: list[int], event: dict[str, Any]) -> None: # Выполняется на потоке loop'а — доступ к _subs безопасен. diff --git a/backend/app/main.py b/backend/app/main.py index bd9484f..a1d0d1e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index 7d2386b..dfb169a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 7f61513..d4908cb 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -14,6 +14,7 @@ from app.services import ( group_service, invitation_service, membership_service, + notification_service, notify, stats_service, ) @@ -155,6 +156,9 @@ def invite_member( ) session.commit() notify.invitations_changed(invited.id) # type: ignore[arg-type] # живое появление у получателя + notification_service.invited_to_group( + session, invited.id, group.name, user.nickname # type: ignore[arg-type] + ) return s.InvitationRead( id=inv.id, # type: ignore[arg-type] group_id=group_id, diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 7ee544d..62a048f 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -16,6 +16,7 @@ from app.services import ( audit_service, group_service, match_service, + notification_service, notify, user_service, ) @@ -121,6 +122,7 @@ def start_match( ) session.commit() notify.match_changed(session, match) + notification_service.match_started(session, match, user.id) # type: ignore[arg-type] return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) @@ -160,6 +162,7 @@ def finish_match( ) session.commit() notify.match_changed(session, match) + notification_service.match_finished(session, match, user.id) # type: ignore[arg-type] return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py new file mode 100644 index 0000000..080ce2a --- /dev/null +++ b/backend/app/routers/notifications.py @@ -0,0 +1,34 @@ +"""Уведомления игрока: список (pull) и отметка прочитанными. + +Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент +перезапрашивает этот список. Хранение — 72 часа; протухшие чистятся при чтении (и фоном).""" +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from app.auth.deps import get_current_user +from app.db.session import get_session +from app.models import User +from app.schemas import api as s +from app.services import notification_service + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + + +@router.get("", response_model=s.NotificationList) +def my_notifications( + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> dict: + return notification_service.list_for_user(session, user.id) # type: ignore[arg-type] + + +@router.post("/read", response_model=s.OkResponse) +def mark_read( + body: s.NotificationMarkRead, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.OkResponse: + notification_service.mark_read(session, user.id, body.ids) # type: ignore[arg-type] + return s.OkResponse() diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index c3b28db..f14c142 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -157,6 +157,27 @@ class InvitationRead(BaseModel): created_at: str +# ─── Уведомления ─────────────────────────────────────────────────────────────── + +class NotificationRead(BaseModel): + id: int + type: str + title: str + body: str | None = None + link: str | None = None + read_at: str | None = None + created_at: str + + +class NotificationList(BaseModel): + items: list[NotificationRead] + unread_count: int + + +class NotificationMarkRead(BaseModel): + ids: list[int] | None = None + + # ─── Партии ────────────────────────────────────────────────────────────────── class RandomizeRequest(BaseModel): diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..2c646b1 --- /dev/null +++ b/backend/app/services/notification_service.py @@ -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", + ) diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py index b39cc04..d2bd665 100644 --- a/backend/app/services/notify.py +++ b/backend/app/services/notify.py @@ -44,3 +44,8 @@ def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | N def invitations_changed(user_id: int) -> None: """У пользователя изменился список приглашений.""" hub.publish([user_id], {"type": "invitations"}) + + +def notifications_changed(user_id: int) -> None: + """У пользователя появилось/изменилось уведомление — пусть подтянет список.""" + hub.publish([user_id], {"type": "notifications"}) diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py new file mode 100644 index 0000000..f76b119 --- /dev/null +++ b/backend/tests/test_notifications.py @@ -0,0 +1,158 @@ +"""Уведомления: сервис (create/list/mark_read/purge) и триггеры (инвайт, старт/финиш партии).""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.models import Notification, User +from app.services import notification_service +from tests.conftest import ( + add_group_member, + csrf_headers, + finish_match, + login, + start_match, +) + + +def _group(client: TestClient, name: str = "Группа") -> int: + return client.post( + "/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + + +def _dev_user(client: TestClient, nick: str) -> None: + client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client)) + + +def _make_user(engine, nick: str) -> int: + with Session(engine) as s: + u = User(nickname=nick, role="player", auth_provider="stub") + s.add(u) + s.commit() + s.refresh(u) + return u.id # type: ignore[return-value] + + +# ─── Сервис ──────────────────────────────────────────────────────────────────── + +def test_create_list_mark_read(engine): + uid = _make_user(engine, "Алиса") + with Session(engine) as s: + notification_service.create(s, uid, type="test", title="Привет", link="/account") + data = notification_service.list_for_user(s, uid) + assert data["unread_count"] == 1 + assert len(data["items"]) == 1 + assert data["items"][0]["title"] == "Привет" + assert data["items"][0]["link"] == "/account" + assert data["items"][0]["read_at"] is None + + marked = notification_service.mark_read(s, uid) + assert marked == 1 + after = notification_service.list_for_user(s, uid) + assert after["unread_count"] == 0 + assert after["items"][0]["read_at"] is not None + + +def test_purge_expired(engine): + uid = _make_user(engine, "Боб") + with Session(engine) as s: + n = Notification(user_id=uid, type="test", title="Старое") + s.add(n) + s.commit() + s.refresh(n) + # Состарим запись за пределы окна хранения. + n.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta( + hours=notification_service.RETENTION_HOURS + 1 + ) + s.add(n) + s.commit() + + removed = notification_service.purge_expired(s) + assert removed == 1 + assert notification_service.list_for_user(s, uid)["items"] == [] + + +def test_notifications_require_auth(client: TestClient): + assert client.get("/api/notifications").status_code == 401 + + +# ─── Триггеры через HTTP ───────────────────────────────────────────────────────── + +def test_invitation_creates_notification(client: TestClient): + login(client, "Овнер") + gid = _group(client) + _dev_user(client, "Гость") + r = client.post( + f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + + login(client, "Гость") + data = client.get("/api/notifications").json() + invites = [n for n in data["items"] if n["type"] == "invite"] + assert len(invites) == 1 + assert invites[0]["link"] == "/group" + assert "Группа" in invites[0]["title"] + assert data["unread_count"] >= 1 + + +def test_match_notifications_exclude_actor(client: TestClient, engine): + me = login(client, "Капитан") + gid = _group(client) + member_id = add_group_member(engine, gid, "Боец") + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + + started = start_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": fids[0]}, + {"user_id": member_id, "faction_id": fids[1]}, + ], + ) + assert started.status_code == 200, started.text + mid = started.json()["id"] + + # Инициатор (Капитан) себе уведомление не шлёт. + mine = client.get("/api/notifications").json() + assert all(n["type"] != "match_started" for n in mine["items"]) + + # Участник получает уведомление о старте со ссылкой на партию. + login(client, "Боец") + his = client.get("/api/notifications").json() + started_notifs = [n for n in his["items"] if n["type"] == "match_started"] + assert len(started_notifs) == 1 + assert started_notifs[0]["link"] == f"/match/{mid}" + + # Финиш (инициатор снова Капитан) → участник получает уведомление о завершении. + login(client, "Капитан") + fin = finish_match( + client, + mid, + [{"user_id": me["id"], "place": 1}, {"user_id": member_id, "place": 2}], + ) + assert fin.status_code == 200, fin.text + + login(client, "Боец") + his2 = client.get("/api/notifications").json() + assert any( + n["type"] == "match_finished" and n["link"] == f"/match/{mid}" for n in his2["items"] + ) + + +def test_mark_read_endpoint(client: TestClient): + login(client, "Овнер2") + gid = _group(client) + _dev_user(client, "Гость2") + client.post( + f"/api/groups/{gid}/invitations", json={"nickname": "Гость2"}, headers=csrf_headers(client) + ) + + login(client, "Гость2") + assert client.get("/api/notifications").json()["unread_count"] >= 1 + r = client.post("/api/notifications/read", json={}, headers=csrf_headers(client)) + assert r.status_code == 200, r.text + assert client.get("/api/notifications").json()["unread_count"] == 0 diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index f54b4ad..cbd05fc 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -16,6 +16,7 @@ export const qk = { match: (id: number) => ["match", id] as const, authConfig: ["authConfig"] as const, invitations: ["invitations"] as const, + notifications: ["notifications"] as const, devUsers: ["devUsers"] as const, adminUsers: ["adminUsers"] as const, adminGroups: ["adminGroups"] as const, diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 0535004..b1876ce 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -662,6 +662,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** My Notifications */ + get: operations["my_notifications_api_notifications_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/notifications/read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark Read */ + post: operations["mark_read_api_notifications_read_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/auth/login": { parameters: { query?: never; @@ -1725,6 +1759,35 @@ export interface components { /** Nickname */ nickname: string; }; + /** NotificationList */ + NotificationList: { + /** Items */ + items: components["schemas"]["NotificationRead"][]; + /** Unread Count */ + unread_count: number; + }; + /** NotificationMarkRead */ + NotificationMarkRead: { + /** Ids */ + ids?: number[] | null; + }; + /** NotificationRead */ + NotificationRead: { + /** Id */ + id: number; + /** Type */ + type: string; + /** Title */ + title: string; + /** Body */ + body?: string | null; + /** Link */ + link?: string | null; + /** Read At */ + read_at?: string | null; + /** Created At */ + created_at: string; + }; /** OkResponse */ OkResponse: { /** @@ -3232,6 +3295,59 @@ export interface operations { }; }; }; + my_notifications_api_notifications_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NotificationList"]; + }; + }; + }; + }; + mark_read_api_notifications_read_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NotificationMarkRead"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OkResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; admin_login_api_admin_auth_login_post: { parameters: { query?: never; diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index dc40575..196fe46 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -3,6 +3,8 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { useMe } from "../hooks/auth"; import { useServerEvents } from "../hooks/useServerEvents"; import { BottomBar } from "./BottomBar"; +import { NotificationBell } from "./NotificationBell"; +import { NotificationToaster } from "./NotificationToaster"; const TITLES: Record = { "/": "Forbidden Stars", @@ -26,16 +28,23 @@ export function AppShell() { return (
- {isHome ? ( - {title} - ) : ( - - )} - {!isHome ? title : ""} +
+ {isHome ? ( + {title} + ) : ( + + )} +
+ {!isHome ? title : ""} +
+ +
+ +
diff --git a/frontend/src/components/NotificationBell.tsx b/frontend/src/components/NotificationBell.tsx new file mode 100644 index 0000000..ef02afb --- /dev/null +++ b/frontend/src/components/NotificationBell.tsx @@ -0,0 +1,42 @@ +import { Bell } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { useMarkNotificationsRead, useNotifications } from "../hooks/notifications"; +import { NotificationPanel } from "./NotificationPanel"; + +/** Колокольчик в правом верхнем углу: бейдж непрочитанных + выпадающая панель. */ +export function NotificationBell({ enabled }: { enabled: boolean }) { + const { data } = useNotifications(enabled); + const markRead = useMarkNotificationsRead(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const unread = data?.unread_count ?? 0; + + // Закрытие панели по клику вне неё. + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + if (!enabled) return null; + + const toggle = () => { + const next = !open; + setOpen(next); + if (next && unread > 0) markRead.mutate(undefined); // открыли — гасим бейдж + }; + + return ( +
+ + {open && setOpen(false)} />} +
+ ); +} diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx new file mode 100644 index 0000000..2cf981d --- /dev/null +++ b/frontend/src/components/NotificationPanel.tsx @@ -0,0 +1,43 @@ +import { useNavigate } from "react-router-dom"; + +import { formatTime } from "../domain/format"; +import type { Notification } from "../hooks/notifications"; + +/** Выпадающий список уведомлений под колокольчиком. Клик по строке ведёт по ссылке. */ +export function NotificationPanel({ + items, + onClose, +}: { + items: Notification[]; + onClose: () => void; +}) { + const navigate = useNavigate(); + + const go = (n: Notification) => { + onClose(); + if (n.link) navigate(n.link); + }; + + return ( +
+
Уведомления
+ {items.length === 0 ? ( +
Уведомлений нет
+ ) : ( +
+ {items.map((n) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/NotificationToaster.tsx b/frontend/src/components/NotificationToaster.tsx new file mode 100644 index 0000000..789a90c --- /dev/null +++ b/frontend/src/components/NotificationToaster.tsx @@ -0,0 +1,50 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useNotifications, type Notification } from "../hooks/notifications"; + +const TOAST_TTL_MS = 5000; +const MAX_VISIBLE = 4; + +/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull). + * Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. */ +export function NotificationToaster({ enabled }: { enabled: boolean }) { + const { data } = useNotifications(enabled); + const navigate = useNavigate(); + const seen = useRef | null>(null); + const [toasts, setToasts] = useState([]); + + useEffect(() => { + if (!data) return; + const items = data.items; + if (seen.current === null) { + seen.current = new Set(items.map((n) => n.id)); // первая загрузка — запомнить, не всплывать + return; + } + const fresh = items.filter((n) => !seen.current!.has(n.id)); + if (fresh.length === 0) return; + fresh.forEach((n) => seen.current!.add(n.id)); + setToasts((prev) => [...fresh, ...prev].slice(0, MAX_VISIBLE)); + fresh.forEach((n) => { + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== n.id)), TOAST_TTL_MS); + }); + }, [data]); + + if (!enabled || toasts.length === 0) return null; + + const click = (n: Notification) => { + setToasts((prev) => prev.filter((t) => t.id !== n.id)); + if (n.link) navigate(n.link); + }; + + return ( +
+ {toasts.map((n) => ( + + ))} +
+ ); +} diff --git a/frontend/src/hooks/notifications.ts b/frontend/src/hooks/notifications.ts new file mode 100644 index 0000000..f2aad93 --- /dev/null +++ b/frontend/src/hooks/notifications.ts @@ -0,0 +1,25 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api, unwrap } from "../api/client"; +import { qk } from "../api/queryKeys"; +import type { components } from "../api/schema"; + +export type Notification = components["schemas"]["NotificationRead"]; + +export function useNotifications(enabled: boolean) { + return useQuery({ + queryKey: qk.notifications, + queryFn: async () => unwrap(await api.GET("/api/notifications")), + enabled, + }); +} + +export function useMarkNotificationsRead() { + const qc = useQueryClient(); + return useMutation({ + // ids не задаём → сервер отметит прочитанными все непрочитанные. + mutationFn: async (ids?: number[]) => + unwrap(await api.POST("/api/notifications/read", { body: { ids: ids ?? null } })), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.notifications }), + }); +} diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts index 8b1f554..8efddda 100644 --- a/frontend/src/hooks/useServerEvents.ts +++ b/frontend/src/hooks/useServerEvents.ts @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { qk } from "../api/queryKeys"; interface ServerEvent { - type: "match" | "group" | "invitations"; + type: "match" | "group" | "invitations" | "notifications"; match_id?: number; group_id?: number; } @@ -30,6 +30,8 @@ export function useServerEvents(enabled: boolean) { } if (ev.type === "invitations") { qc.invalidateQueries({ queryKey: qk.invitations }); + } else if (ev.type === "notifications") { + qc.invalidateQueries({ queryKey: qk.notifications }); } else if (ev.type === "match") { if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) }); if (ev.group_id != null) { diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 0c5a6e2..46c2deb 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -107,3 +107,110 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } text-align: center; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); } + +/* ─── Уведомления ─────────────────────────────────────────────────────────── */ + +/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */ +.top-bar-left { display: flex; align-items: center; min-width: 0; } +.top-bar-center { + flex: 1; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 8px; +} +.top-bar-right { display: flex; align-items: center; } + +.notif-bell-wrap { position: relative; } +.notif-bell { + position: relative; + display: grid; + place-items: center; + width: 38px; + height: 38px; + border: none; + background: none; + color: var(--text); + border-radius: 50%; +} +.notif-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 16px; + text-align: center; +} + +.notif-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: min(320px, 86vw); + max-height: 70vh; + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + z-index: 50; +} +.notif-panel-head { + padding: 10px 14px; + font-weight: 700; + border-bottom: 1px solid var(--border); +} +.notif-empty { padding: 18px 14px; text-align: center; } +.notif-list { display: flex; flex-direction: column; } +.notif-item { + display: block; + width: 100%; + text-align: left; + padding: 10px 14px; + background: none; + border: none; + border-bottom: 1px solid var(--border); + color: var(--text); +} +.notif-item:last-child { border-bottom: none; } +.notif-item.unread { background: var(--surface-2); } +.notif-item-title { font-weight: 600; } +.notif-item-body { margin-top: 2px; } +.notif-item-time { margin-top: 4px; } + +/* Всплывашки сверху экрана в момент прихода */ +.toast-top-wrap { + position: fixed; + top: calc(var(--safe-top) + 10px); + left: 50%; + transform: translateX(-50%); + width: min(var(--app-max-w), 94vw); + display: flex; + flex-direction: column; + gap: 8px; + z-index: 200; + pointer-events: none; +} +.toast-top { + pointer-events: auto; + display: block; + width: 100%; + text-align: left; + background: var(--surface-2); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + color: var(--text); + padding: 10px 14px; + border-radius: var(--radius-sm); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); +} +.toast-top-title { font-weight: 700; } +.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }