Уведомления: 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:
@@ -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")
|
||||||
@@ -37,11 +37,14 @@ class EventHub:
|
|||||||
def publish(self, user_ids: Iterable[int], event: dict[str, Any]) -> None:
|
def publish(self, user_ids: Iterable[int], event: dict[str, Any]) -> None:
|
||||||
"""Доставить событие подписчикам с указанными user_id (потокобезопасно)."""
|
"""Доставить событие подписчикам с указанными user_id (потокобезопасно)."""
|
||||||
loop = self._loop
|
loop = self._loop
|
||||||
if loop is None:
|
if loop is None or loop.is_closed():
|
||||||
return
|
return
|
||||||
ids = [uid for uid in user_ids if uid is not None]
|
ids = [uid for uid in user_ids if uid is not None]
|
||||||
if ids:
|
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:
|
def _fanout(self, ids: list[int], event: dict[str, Any]) -> None:
|
||||||
# Выполняется на потоке loop'а — доступ к _subs безопасен.
|
# Выполняется на потоке loop'а — доступ к _subs безопасен.
|
||||||
|
|||||||
+33
-2
@@ -22,6 +22,7 @@ from app.routers import (
|
|||||||
groups,
|
groups,
|
||||||
invitations,
|
invitations,
|
||||||
matches,
|
matches,
|
||||||
|
notifications,
|
||||||
reference,
|
reference,
|
||||||
stats,
|
stats,
|
||||||
users,
|
users,
|
||||||
@@ -71,6 +72,31 @@ class CSRFMiddleware:
|
|||||||
await self.app(scope, receive, send)
|
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
|
@asynccontextmanager
|
||||||
async def _lifespan(_app: FastAPI):
|
async def _lifespan(_app: FastAPI):
|
||||||
# SSE-шина публикует из sync-роутеров в этот event-loop — сохраняем ссылку (все окружения).
|
# SSE-шина публикует из sync-роутеров в этот event-loop — сохраняем ссылку (все окружения).
|
||||||
@@ -91,7 +117,12 @@ async def _lifespan(_app: FastAPI):
|
|||||||
logging.getLogger("fs").warning(
|
logging.getLogger("fs").warning(
|
||||||
"Стартовый bootstrap пропущен (примените миграции): %s", exc
|
"Стартовый bootstrap пропущен (примените миграции): %s", exc
|
||||||
)
|
)
|
||||||
yield
|
|
||||||
|
purge_task = asyncio.create_task(_notifications_purge_loop())
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
purge_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -135,7 +166,7 @@ def create_app() -> FastAPI:
|
|||||||
# API-роутеры под /api.
|
# API-роутеры под /api.
|
||||||
api_routers = [auth.router, users.router, groups.router, invitations.router,
|
api_routers = [auth.router, users.router, groups.router, invitations.router,
|
||||||
matches.router, reference.router, stats.router, achievements.router,
|
matches.router, reference.router, stats.router, achievements.router,
|
||||||
events.router, admin.router]
|
events.router, notifications.router, admin.router]
|
||||||
for r in api_routers:
|
for r in api_routers:
|
||||||
app.include_router(r, prefix="/api")
|
app.include_router(r, prefix="/api")
|
||||||
|
|
||||||
|
|||||||
@@ -336,6 +336,33 @@ class MatchAttachment(SQLModel, table=True):
|
|||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
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):
|
class AuditLog(SQLModel, table=True):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.services import (
|
|||||||
group_service,
|
group_service,
|
||||||
invitation_service,
|
invitation_service,
|
||||||
membership_service,
|
membership_service,
|
||||||
|
notification_service,
|
||||||
notify,
|
notify,
|
||||||
stats_service,
|
stats_service,
|
||||||
)
|
)
|
||||||
@@ -155,6 +156,9 @@ def invite_member(
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.invitations_changed(invited.id) # type: ignore[arg-type] # живое появление у получателя
|
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(
|
return s.InvitationRead(
|
||||||
id=inv.id, # type: ignore[arg-type]
|
id=inv.id, # type: ignore[arg-type]
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.services import (
|
|||||||
audit_service,
|
audit_service,
|
||||||
group_service,
|
group_service,
|
||||||
match_service,
|
match_service,
|
||||||
|
notification_service,
|
||||||
notify,
|
notify,
|
||||||
user_service,
|
user_service,
|
||||||
)
|
)
|
||||||
@@ -121,6 +122,7 @@ def start_match(
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_changed(session, match)
|
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))
|
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||||
|
|
||||||
|
|
||||||
@@ -160,6 +162,7 @@ def finish_match(
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_changed(session, match)
|
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))
|
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -157,6 +157,27 @@ class InvitationRead(BaseModel):
|
|||||||
created_at: str
|
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):
|
class RandomizeRequest(BaseModel):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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:
|
def invitations_changed(user_id: int) -> None:
|
||||||
"""У пользователя изменился список приглашений."""
|
"""У пользователя изменился список приглашений."""
|
||||||
hub.publish([user_id], {"type": "invitations"})
|
hub.publish([user_id], {"type": "invitations"})
|
||||||
|
|
||||||
|
|
||||||
|
def notifications_changed(user_id: int) -> None:
|
||||||
|
"""У пользователя появилось/изменилось уведомление — пусть подтянет список."""
|
||||||
|
hub.publish([user_id], {"type": "notifications"})
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -16,6 +16,7 @@ export const qk = {
|
|||||||
match: (id: number) => ["match", id] as const,
|
match: (id: number) => ["match", id] as const,
|
||||||
authConfig: ["authConfig"] as const,
|
authConfig: ["authConfig"] as const,
|
||||||
invitations: ["invitations"] as const,
|
invitations: ["invitations"] as const,
|
||||||
|
notifications: ["notifications"] as const,
|
||||||
devUsers: ["devUsers"] as const,
|
devUsers: ["devUsers"] as const,
|
||||||
adminUsers: ["adminUsers"] as const,
|
adminUsers: ["adminUsers"] as const,
|
||||||
adminGroups: ["adminGroups"] as const,
|
adminGroups: ["adminGroups"] as const,
|
||||||
|
|||||||
Vendored
+116
@@ -662,6 +662,40 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/admin/auth/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1725,6 +1759,35 @@ export interface components {
|
|||||||
/** Nickname */
|
/** Nickname */
|
||||||
nickname: string;
|
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 */
|
||||||
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: {
|
admin_login_api_admin_auth_login_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
import { useServerEvents } from "../hooks/useServerEvents";
|
import { useServerEvents } from "../hooks/useServerEvents";
|
||||||
import { BottomBar } from "./BottomBar";
|
import { BottomBar } from "./BottomBar";
|
||||||
|
import { NotificationBell } from "./NotificationBell";
|
||||||
|
import { NotificationToaster } from "./NotificationToaster";
|
||||||
|
|
||||||
const TITLES: Record<string, string> = {
|
const TITLES: Record<string, string> = {
|
||||||
"/": "Forbidden Stars",
|
"/": "Forbidden Stars",
|
||||||
@@ -26,16 +28,23 @@ export function AppShell() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="top-bar">
|
<header className="top-bar">
|
||||||
{isHome ? (
|
<div className="top-bar-left">
|
||||||
<span className="title">{title}</span>
|
{isHome ? (
|
||||||
) : (
|
<span className="title">{title}</span>
|
||||||
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
|
) : (
|
||||||
← Назад
|
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
|
||||||
</button>
|
← Назад
|
||||||
)}
|
</button>
|
||||||
<span className="title small muted">{!isHome ? title : ""}</span>
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="title small muted top-bar-center">{!isHome ? title : ""}</span>
|
||||||
|
<div className="top-bar-right">
|
||||||
|
<NotificationBell enabled={!!me} />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<NotificationToaster enabled={!!me} />
|
||||||
|
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="notif-bell-wrap" ref={ref}>
|
||||||
|
<button className="notif-bell" aria-label="Уведомления" onClick={toggle}>
|
||||||
|
<Bell size={20} />
|
||||||
|
{unread > 0 && <span className="notif-badge">{unread > 99 ? "99+" : unread}</span>}
|
||||||
|
</button>
|
||||||
|
{open && <NotificationPanel items={data?.items ?? []} onClose={() => setOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="notif-panel">
|
||||||
|
<div className="notif-panel-head">Уведомления</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="notif-empty muted small">Уведомлений нет</div>
|
||||||
|
) : (
|
||||||
|
<div className="notif-list">
|
||||||
|
{items.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
className={`notif-item${n.read_at ? "" : " unread"}`}
|
||||||
|
onClick={() => go(n)}
|
||||||
|
>
|
||||||
|
<div className="notif-item-title">{n.title}</div>
|
||||||
|
{n.body && <div className="notif-item-body muted small">{n.body}</div>}
|
||||||
|
<div className="notif-item-time muted small">{formatTime(n.created_at)}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Set<number> | null>(null);
|
||||||
|
const [toasts, setToasts] = useState<Notification[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="toast-top-wrap">
|
||||||
|
{toasts.map((n) => (
|
||||||
|
<button key={n.id} className="toast-top" onClick={() => click(n)}>
|
||||||
|
<div className="toast-top-title">{n.title}</div>
|
||||||
|
{n.body && <div className="toast-top-body">{n.body}</div>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
|||||||
import { qk } from "../api/queryKeys";
|
import { qk } from "../api/queryKeys";
|
||||||
|
|
||||||
interface ServerEvent {
|
interface ServerEvent {
|
||||||
type: "match" | "group" | "invitations";
|
type: "match" | "group" | "invitations" | "notifications";
|
||||||
match_id?: number;
|
match_id?: number;
|
||||||
group_id?: number;
|
group_id?: number;
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,8 @@ export function useServerEvents(enabled: boolean) {
|
|||||||
}
|
}
|
||||||
if (ev.type === "invitations") {
|
if (ev.type === "invitations") {
|
||||||
qc.invalidateQueries({ queryKey: qk.invitations });
|
qc.invalidateQueries({ queryKey: qk.invitations });
|
||||||
|
} else if (ev.type === "notifications") {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.notifications });
|
||||||
} else if (ev.type === "match") {
|
} else if (ev.type === "match") {
|
||||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||||
if (ev.group_id != null) {
|
if (ev.group_id != null) {
|
||||||
|
|||||||
@@ -107,3 +107,110 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user