Группы: приглашения вместо авто-добавления (согласиться/отказаться)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 20:07:20 +03:00
co-authored by Claude Opus 4.8
parent 66370c7df4
commit a5c54512a5
20 changed files with 742 additions and 98 deletions
@@ -0,0 +1,60 @@
"""Приглашения в группу: таблица group_invitations.
Идемпотентна (как 0005): на свежей БД таблицу создаёт 0001 (create_all из актуальных
моделей); на существующей — create_table здесь.
Revision ID: 0006_group_invitations
Revises: 0005_achievements
Create Date: 2026-06-17
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0006_group_invitations"
down_revision: Union[str, None] = "0005_achievements"
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 "group_invitations" in insp.get_table_names():
return
op.create_table(
"group_invitations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"invited_by_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_invitation"),
)
op.create_index("ix_group_invitations_group_id", "group_invitations", ["group_id"])
op.create_index("ix_group_invitations_user_id", "group_invitations", ["user_id"])
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "group_invitations" in insp.get_table_names():
op.drop_table("group_invitations")
+14 -3
View File
@@ -15,7 +15,17 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.core import security from app.core import security
from app.core.config import settings from app.core.config import settings
from app.core.errors import AppError, app_error_handler from app.core.errors import AppError, app_error_handler
from app.routers import achievements, admin, auth, groups, matches, reference, stats, users from app.routers import (
achievements,
admin,
auth,
groups,
invitations,
matches,
reference,
stats,
users,
)
# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать). # Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static"))) _STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static")))
@@ -106,8 +116,9 @@ def create_app() -> FastAPI:
) )
# API-роутеры под /api. # API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, matches.router, api_routers = [auth.router, users.router, groups.router, invitations.router,
reference.router, stats.router, achievements.router, admin.router] matches.router, reference.router, stats.router, achievements.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")
+28
View File
@@ -188,6 +188,34 @@ class GroupMember(SQLModel, table=True):
joined_at: datetime = Field(default_factory=_utcnow, nullable=False) joined_at: datetime = Field(default_factory=_utcnow, nullable=False)
class GroupInvitation(SQLModel, table=True):
"""Ожидающее приглашение в группу. Хранятся только pending: при ответе строка
удаляется (accept → создаётся GroupMember; decline → просто удаление)."""
__tablename__ = "group_invitations"
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_invitation"),
)
id: int | None = Field(default=None, primary_key=True)
group_id: int = Field(
sa_column=Column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
)
invited_by_id: int | None = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
)
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
class GroupExpansion(SQLModel, table=True): class GroupExpansion(SQLModel, table=True):
__tablename__ = "group_expansions" __tablename__ = "group_expansions"
__table_args__ = ( __table_args__ = (
+16 -7
View File
@@ -5,12 +5,14 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user from app.auth.deps import get_current_user
from app.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
from app.schemas import api as s from app.schemas import api as s
from app.services import ( from app.services import (
audit_service, audit_service,
group_service, group_service,
invitation_service,
membership_service, membership_service,
stats_service, stats_service,
) )
@@ -128,29 +130,36 @@ def list_members(
] ]
@router.post("/{group_id}/members", response_model=s.MemberRead) @router.post("/{group_id}/invitations", response_model=s.InvitationRead)
def add_member( def invite_member(
group_id: int, group_id: int,
body: s.MemberAdd, body: s.MemberAdd,
request: Request, request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.MemberRead: ) -> s.InvitationRead:
# Приглашать вправе ЛЮБОЙ участник группы (не только владелец) — assert_member.
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id) group = group_service.get_group(session, group_id)
member, added = membership_service.add_member_by_nickname(session, group, body.nickname) inv, invited = invitation_service.create_invitation(session, group, user, body.nickname)
audit_service.record( audit_service.record(
session, session,
actor_id=user.id, actor_id=user.id,
action="create", action="create",
entity_type="group_member", entity_type="group_invitation",
entity_id=group_id, entity_id=group_id,
payload={"added_user_id": added.id, "nickname": added.nickname}, payload={"invited_user_id": invited.id, "nickname": invited.nickname},
ip=request.client.host if request.client else None, ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
return s.MemberRead(user_id=added.id, nickname=added.nickname, role=member.role) return s.InvitationRead(
id=inv.id, # type: ignore[arg-type]
group_id=group_id,
group_name=group.name,
invited_by_nickname=user.nickname,
created_at=iso_utc(inv.created_at),
)
@router.delete("/{group_id}/members/{user_id}", response_model=s.OkResponse) @router.delete("/{group_id}/members/{user_id}", response_model=s.OkResponse)
+41
View File
@@ -0,0 +1,41 @@
"""Входящие приглашения игрока: список, принятие, отклонение."""
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 invitation_service
router = APIRouter(prefix="/invitations", tags=["invitations"])
@router.get("", response_model=list[s.InvitationRead])
def my_invitations(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> list[dict]:
return invitation_service.list_for_user(session, user.id) # type: ignore[arg-type]
@router.post("/{invitation_id}/accept", response_model=s.OkResponse)
def accept_invitation(
invitation_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
invitation_service.accept(session, user, invitation_id)
return s.OkResponse()
@router.post("/{invitation_id}/decline", response_model=s.OkResponse)
def decline_invitation(
invitation_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
invitation_service.decline(session, user, invitation_id)
return s.OkResponse()
+8
View File
@@ -149,6 +149,14 @@ class MemberRoleUpdate(BaseModel):
role: str role: str
class InvitationRead(BaseModel):
id: int
group_id: int
group_name: str
invited_by_nickname: str | None = None
created_at: str
# ─── Партии ────────────────────────────────────────────────────────────────── # ─── Партии ──────────────────────────────────────────────────────────────────
class RandomizeRequest(BaseModel): class RandomizeRequest(BaseModel):
+130
View File
@@ -0,0 +1,130 @@
"""Приглашения в группу: создание (любым участником), список, принятие, отклонение.
Хранятся только ожидающие приглашения; ответ удаляет строку (accept → создаётся
GroupMember). Лимит участников — общий с membership_service.MAX_GROUP_SIZE.
"""
from __future__ import annotations
from sqlmodel import Session, select
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.core.timeutil import iso_utc
from app.models import Group, GroupInvitation, GroupMember, User
from app.services.membership_service import MAX_GROUP_SIZE
def _member_exists(session: Session, group_id: int, user_id: int) -> bool:
return (
session.exec(
select(GroupMember.id).where(
GroupMember.group_id == group_id, GroupMember.user_id == user_id
)
).first()
is not None
)
def _counts(session: Session, group_id: int) -> tuple[int, int]:
members = len(
session.exec(select(GroupMember.id).where(GroupMember.group_id == group_id)).all()
)
pending = len(
session.exec(
select(GroupInvitation.id).where(GroupInvitation.group_id == group_id)
).all()
)
return members, pending
def create_invitation(
session: Session, group: Group, inviter: User, nickname: str
) -> tuple[GroupInvitation, User]:
nickname = (nickname or "").strip()
if not nickname:
raise ValidationError("Укажите никнейм игрока.")
user = session.exec(select(User).where(User.nickname == nickname)).first()
if user is None:
raise NotFoundError("Игрок с таким ником не найден.")
if _member_exists(session, group.id, user.id): # type: ignore[arg-type]
raise ConflictError("Игрок уже в группе.")
existing = session.exec(
select(GroupInvitation).where(
GroupInvitation.group_id == group.id, GroupInvitation.user_id == user.id
)
).first()
if existing is not None:
raise ConflictError("Игрок уже приглашён в эту группу.")
members, pending = _counts(session, group.id) # type: ignore[arg-type]
if members + pending >= MAX_GROUP_SIZE:
raise ConflictError(f"В группе нет мест (максимум {MAX_GROUP_SIZE}).")
inv = GroupInvitation(
group_id=group.id, user_id=user.id, invited_by_id=inviter.id # type: ignore[arg-type]
)
session.add(inv)
session.commit()
session.refresh(inv)
return inv, user
def list_for_user(session: Session, user_id: int) -> list[dict]:
rows = session.exec(
select(GroupInvitation, Group.name).join(Group, Group.id == GroupInvitation.group_id)
.where(GroupInvitation.user_id == user_id)
.order_by(GroupInvitation.created_at.desc())
).all()
out = []
for inv, gname in rows:
inviter_nick = None
if inv.invited_by_id is not None:
inviter = session.get(User, inv.invited_by_id)
inviter_nick = inviter.nickname if inviter else None
out.append(
{
"id": inv.id,
"group_id": inv.group_id,
"group_name": gname,
"invited_by_nickname": inviter_nick,
"created_at": iso_utc(inv.created_at),
}
)
return out
def _own_invitation(session: Session, user: User, invitation_id: int) -> GroupInvitation:
inv = session.get(GroupInvitation, invitation_id)
if inv is None or inv.user_id != user.id:
raise NotFoundError("Приглашение не найдено.")
return inv
def accept(session: Session, user: User, invitation_id: int) -> Group:
inv = _own_invitation(session, user, invitation_id)
group = session.get(Group, inv.group_id)
if group is None:
session.delete(inv)
session.commit()
raise NotFoundError("Группа не найдена.")
if not _member_exists(session, group.id, user.id): # type: ignore[arg-type]
members, _ = _counts(session, group.id) # type: ignore[arg-type]
if members >= MAX_GROUP_SIZE:
raise ConflictError(f"В группе нет мест (максимум {MAX_GROUP_SIZE}).")
session.add(
GroupMember(group_id=group.id, user_id=user.id, role="member") # type: ignore[arg-type]
)
if user.active_group_id is None: # первая группа — делаем активной
user.active_group_id = group.id
session.add(user)
session.delete(inv)
session.commit()
return group
def decline(session: Session, user: User, invitation_id: int) -> None:
inv = _own_invitation(session, user, invitation_id)
session.delete(inv)
session.commit()
+28 -2
View File
@@ -15,13 +15,13 @@ os.environ["FS_STARTUP_BOOTSTRAP"] = "0" # тесты сами поднимаю
import pytest # noqa: E402 import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402 from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402 from sqlalchemy.pool import StaticPool # noqa: E402
from sqlmodel import Session, SQLModel, create_engine # noqa: E402 from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
import app.models # noqa: F401,E402 (регистрация моделей) import app.models # noqa: F401,E402 (регистрация моделей)
from app.core.security import hash_password # noqa: E402 from app.core.security import hash_password # noqa: E402
from app.db.session import get_session # noqa: E402 from app.db.session import get_session # noqa: E402
from app.main import app # noqa: E402 from app.main import app # noqa: E402
from app.models import User # noqa: E402 from app.models import AuthIdentity, GroupMember, User # noqa: E402
from app.seed.reference_data import seed_reference_data # noqa: E402 from app.seed.reference_data import seed_reference_data # noqa: E402
@@ -86,6 +86,32 @@ def login(client: TestClient, nickname: str) -> dict:
return r.json() return r.json()
def add_group_member(engine, group_id: int, nickname: str, role: str = "member") -> int:
"""Подготовка данных: добавить игрока в группу напрямую (в обход приглашений).
Создаёт пользователя при отсутствии. Возвращает user_id. Нужен, т.к. через HTTP
прямого добавления больше нет — только приглашения (см. invitation_service)."""
with Session(engine) as s:
user = s.exec(select(User).where(User.nickname == nickname)).first()
if user is None:
user = User(nickname=nickname, role="player", auth_provider="stub")
s.add(user)
s.commit()
s.refresh(user)
# AuthIdentity нужен, чтобы dev-вход (login) нашёл того же игрока, а не создал дубль.
s.add(AuthIdentity(user_id=user.id, provider="stub", external_id=nickname))
s.commit()
exists = s.exec(
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == user.id
)
).first()
if exists is None:
s.add(GroupMember(group_id=group_id, user_id=user.id, role=role))
s.commit()
return user.id # type: ignore[return-value]
def start_match(client: TestClient, group_id: int, roster: list[dict]): def start_match(client: TestClient, group_id: int, roster: list[dict]):
"""roster: [{user_id, faction_id, was_random?}] → ответ старта (in_progress).""" """roster: [{user_id, faction_id, was_random?}] → ответ старта (in_progress)."""
return client.post( return client.post(
+12 -27
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.conftest import create_finished_match, csrf_headers, login, start_match from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
def _exp_id(client: TestClient, code: str) -> int: def _exp_id(client: TestClient, code: str) -> int:
@@ -20,26 +20,25 @@ def _admin_login(client: TestClient, make_admin) -> None:
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
def test_group_size_limit(client: TestClient): def test_group_invite_capacity_limit(client: TestClient):
login(client, "Owner") login(client, "Owner")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Большая", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Большая", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
# owner = 1; добавляем до 10, 10-й участник сверх лимита → 409.
for i in range(1, 11): for i in range(1, 11):
client.post("/api/auth/dev/users", json={"nickname": f"U{i}"}, headers=csrf_headers(client)) client.post("/api/auth/dev/users", json={"nickname": f"U{i}"}, headers=csrf_headers(client))
statuses = [] statuses = []
for i in range(1, 11): for i in range(1, 11):
r = client.post( r = client.post(
f"/api/groups/{gid}/members", json={"nickname": f"U{i}"}, headers=csrf_headers(client) f"/api/groups/{gid}/invitations", json={"nickname": f"U{i}"}, headers=csrf_headers(client)
) )
statuses.append(r.status_code) statuses.append(r.status_code)
# 9 успешных (итого 10 с владельцем), 10-й — отказ. # owner(1) + 9 приглашений = 10 (лимит мест); 10-е приглашение сверх лимита → 409.
assert statuses[:9] == [200] * 9 assert statuses[:9] == [200] * 9
assert statuses[9] == 409 assert statuses[9] == 409
def test_match_player_limit(client: TestClient): def test_match_player_limit(client: TestClient, engine):
me = login(client, "Owner") me = login(client, "Owner")
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids") fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
gid = client.post( gid = client.post(
@@ -47,12 +46,7 @@ def test_match_player_limit(client: TestClient):
).json()["id"] ).json()["id"]
ids = [me["id"]] ids = [me["id"]]
for i in range(1, 7): for i in range(1, 7):
client.post("/api/auth/dev/users", json={"nickname": f"P{i}"}, headers=csrf_headers(client)) ids.append(add_group_member(engine, gid, f"P{i}"))
ids.append(
client.post(
f"/api/groups/{gid}/members", json={"nickname": f"P{i}"}, headers=csrf_headers(client)
).json()["user_id"]
)
factions = client.get(f"/api/groups/{gid}/factions").json() factions = client.get(f"/api/groups/{gid}/factions").json()
fids = [f["id"] for f in factions] fids = [f["id"] for f in factions]
# 7 игроков на старте → 422 (максимум 6). # 7 игроков на старте → 422 (максимум 6).
@@ -62,15 +56,12 @@ def test_match_player_limit(client: TestClient):
assert "6" in r.json()["error"]["message"] assert "6" in r.json()["error"]["message"]
def test_admin_edit_match_and_filters(client: TestClient, make_admin): def test_admin_edit_match_and_filters(client: TestClient, make_admin, engine):
me = login(client, "Аня") me = login(client, "Аня")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client)) b = add_group_member(engine, gid, "Боря")
b = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
).json()["user_id"]
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()} factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
mid = create_finished_match( mid = create_finished_match(
client, client,
@@ -129,17 +120,14 @@ def test_admin_rename_account(client: TestClient, make_admin):
assert r.status_code == 422, r.text assert r.status_code == 422, r.text
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin): def test_dev_delete_account_keeps_matches(client: TestClient, make_admin, engine):
"""DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается), """DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается),
его созданные партии и группы переназначаются на админа.""" его созданные партии и группы переназначаются на админа."""
me = login(client, "Аня") # владелец группы и создатель партии me = login(client, "Аня") # владелец группы и создатель партии
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client)) b = add_group_member(engine, gid, "Боря")
b = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
).json()["user_id"]
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()} factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
mid = create_finished_match( mid = create_finished_match(
client, client,
@@ -187,15 +175,12 @@ def test_dev_delete_account_rejects_admin(client: TestClient, make_admin):
assert r.status_code == 422, r.text assert r.status_code == 422, r.text
def test_admin_rename_faction_system_wide(client: TestClient, make_admin): def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine):
me = login(client, "Кто-то") me = login(client, "Кто-то")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Друг"}, headers=csrf_headers(client)) b = add_group_member(engine, gid, "Друг")
b = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Друг"}, headers=csrf_headers(client)
).json()["user_id"]
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()} factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
mid = create_finished_match( mid = create_finished_match(
client, client,
+14 -36
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.conftest import csrf_headers, finish_match, login, start_match from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
def _expansion_id(client: TestClient, code: str) -> int: def _expansion_id(client: TestClient, code: str) -> int:
@@ -12,7 +12,7 @@ def _expansion_id(client: TestClient, code: str) -> int:
return next(e["id"] for e in r.json() if e["code"] == code) return next(e["id"] for e in r.json() if e["code"] == code)
def test_full_flow(client: TestClient, make_admin): def test_full_flow(client: TestClient, make_admin, engine):
# 1) Вход игрока A — групп ещё нет. # 1) Вход игрока A — групп ещё нет.
me = login(client, "Иван") me = login(client, "Иван")
assert me["groups"] == [] assert me["groups"] == []
@@ -44,18 +44,8 @@ def test_full_flow(client: TestClient, make_admin):
me = client.get("/api/users/me").json() me = client.get("/api/users/me").json()
assert me["active_group_id"] == gid assert me["active_group_id"] == gid
# 4) Создать игрока B и добавить в группу по нику. # 4) Игрок B в группе (прямое добавление — подготовка данных; UX-путь теперь приглашения).
rb = client.post( b_id = add_group_member(engine, gid, "Олег")
"/api/auth/dev/users", json={"nickname": "Олег"}, headers=csrf_headers(client)
)
assert rb.status_code == 200, rb.text
r = client.post(
f"/api/groups/{gid}/members",
json={"nickname": "Олег"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
b_id = r.json()["user_id"]
# 5) Доступные фракции = база (4) + Forgotten Worlds (4) = 8. # 5) Доступные фракции = база (4) + Forgotten Worlds (4) = 8.
factions = client.get(f"/api/groups/{gid}/factions").json() factions = client.get(f"/api/groups/{gid}/factions").json()
@@ -150,15 +140,12 @@ def test_full_flow(client: TestClient, make_admin):
assert {"Иван", "Олег"}.issubset(nicks) assert {"Иван", "Олег"}.issubset(nicks)
def test_in_progress_excluded_and_win_reason_required(client: TestClient): def test_in_progress_excluded_and_win_reason_required(client: TestClient, engine):
me = login(client, "Хост") me = login(client, "Хост")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Гость2"}, headers=csrf_headers(client)) b = add_group_member(engine, gid, "Гость2")
b = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Гость2"}, headers=csrf_headers(client)
).json()["user_id"]
factions = client.get(f"/api/groups/{gid}/factions").json() factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2 = factions[0]["id"], factions[1]["id"] f1, f2 = factions[0]["id"], factions[1]["id"]
@@ -202,16 +189,13 @@ def test_in_progress_excluded_and_win_reason_required(client: TestClient):
assert all(x["id"] != mid for x in client.get("/api/home").json()["in_progress"]) assert all(x["id"] != mid for x in client.get("/api/home").json()["in_progress"])
def test_owner_can_finish_member_started_match(client: TestClient): def test_owner_can_finish_member_started_match(client: TestClient, engine):
"""Владелец группы может завершить партию, начатую другим участником (can_modify с бэкенда).""" """Владелец группы может завершить партию, начатую другим участником (can_modify с бэкенда)."""
a = login(client, "Хозяин") a = login(client, "Хозяин")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Согрупник"}, headers=csrf_headers(client)) b = add_group_member(engine, gid, "Согрупник")
b = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Согрупник"}, headers=csrf_headers(client)
).json()["user_id"]
factions = client.get(f"/api/groups/{gid}/factions").json() factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2 = factions[0]["id"], factions[1]["id"] f1, f2 = factions[0]["id"], factions[1]["id"]
@@ -236,16 +220,14 @@ def test_owner_can_finish_member_started_match(client: TestClient):
assert fin.status_code == 200, fin.text assert fin.status_code == 200, fin.text
def test_any_member_can_finish_and_manage(client: TestClient): def test_any_member_can_finish_and_manage(client: TestClient, engine):
"""Рядовой участник (не создатель, не владелец) может завершать партии и менять дополнения.""" """Рядовой участник (не создатель, не владелец) может завершать партии и менять дополнения."""
a = login(client, "Овнер") a = login(client, "Овнер")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Компания", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Компания", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
for nick in ("Бэ", "Цэ"): b = add_group_member(engine, gid, "Бэ")
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client)) c = add_group_member(engine, gid, "Цэ")
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "Бэ"}, headers=csrf_headers(client)).json()["user_id"]
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "Цэ"}, headers=csrf_headers(client)).json()["user_id"]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
# Владелец стартует партию A+B+C. # Владелец стартует партию A+B+C.
@@ -305,18 +287,14 @@ def test_disabled_account_cannot_login(client: TestClient, make_admin):
assert guest_dev["is_active"] is False assert guest_dev["is_active"] is False
def test_tie_ranking_and_points(client: TestClient): def test_tie_ranking_and_points(client: TestClient, engine):
"""Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется.""" """Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется."""
me = login(client, "A") me = login(client, "A")
r = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []}, r = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []},
headers=csrf_headers(client)) headers=csrf_headers(client))
gid = r.json()["id"] gid = r.json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "B"}, headers=csrf_headers(client)) b = add_group_member(engine, gid, "B")
client.post("/api/auth/dev/users", json={"nickname": "C"}, headers=csrf_headers(client)) c = add_group_member(engine, gid, "C")
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "B"},
headers=csrf_headers(client)).json()["user_id"]
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "C"},
headers=csrf_headers(client)).json()["user_id"]
factions = client.get(f"/api/groups/{gid}/factions").json() factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2, f3 = (factions[0]["id"], factions[1]["id"], factions[2]["id"]) f1, f2, f3 = (factions[0]["id"], factions[1]["id"], factions[2]["id"])
a = me["id"] a = me["id"]
+97
View File
@@ -0,0 +1,97 @@
"""Приглашения в группу: создание, просмотр, принятие/отклонение, права участника."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, login
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 test_invite_accept_flow(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
assert r.json()["invited_by_nickname"] == "Овнер"
# Повторное приглашение → 409.
dup = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
)
assert dup.status_code == 409, dup.text
# Приглашённый видит приглашение и принимает.
login(client, "Гость")
invs = client.get("/api/invitations").json()
assert len(invs) == 1
assert invs[0]["group_id"] == gid and invs[0]["group_name"] == "Группа"
inv_id = invs[0]["id"]
acc = client.post(f"/api/invitations/{inv_id}/accept", headers=csrf_headers(client))
assert acc.status_code == 200, acc.text
# Стал участником; первая группа стала активной; приглашение исчезло.
me = client.get("/api/users/me").json()
assert me["active_group_id"] == gid
assert client.get("/api/invitations").json() == []
# Теперь он участник → приглашать его повторно нельзя.
login(client, "Овнер")
again = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
)
assert again.status_code == 409, again.text
members = client.get(f"/api/groups/{gid}/members").json()
assert any(m["nickname"] == "Гость" for m in members)
def test_invite_decline(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")
inv_id = client.get("/api/invitations").json()[0]["id"]
dec = client.post(f"/api/invitations/{inv_id}/decline", headers=csrf_headers(client))
assert dec.status_code == 200, dec.text
assert client.get("/api/invitations").json() == []
login(client, "Овнер2")
members = client.get(f"/api/groups/{gid}/members").json()
assert all(m["nickname"] != "Гость2" for m in members)
def test_any_member_can_invite(client: TestClient, engine):
login(client, "Овнер3")
gid = _group(client)
add_group_member(engine, gid, "Участник") # обычный member (не владелец)
_dev_user(client, "Новичок")
# Обычный участник входит и приглашает — должно быть можно (assert_member, не owner).
login(client, "Участник")
r = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Новичок"}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
def test_invite_unknown_nick_404(client: TestClient):
login(client, "Овнер4")
gid = _group(client)
r = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "НетТакого"}, headers=csrf_headers(client)
)
assert r.status_code == 404, r.text
+5 -8
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.conftest import create_finished_match, csrf_headers, login from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип. # Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
@@ -15,16 +15,13 @@ def _use_tmp_uploads(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path)) monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path))
def _finished_match_for(client: TestClient, me: dict) -> int: def _finished_match_for(client: TestClient, engine, me: dict) -> int:
"""Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд).""" """Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд)."""
exps = [e["id"] for e in client.get("/api/expansions").json()] exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Соперник"}, headers=csrf_headers(client)) p2 = add_group_member(engine, gid, "Соперник")
p2 = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Соперник"}, headers=csrf_headers(client)
).json()["user_id"]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
create_finished_match( create_finished_match(
client, client,
@@ -110,10 +107,10 @@ def test_public_profile(client: TestClient):
assert client.get("/api/users/999999/profile").status_code == 404 assert client.get("/api/users/999999/profile").status_code == 404
def test_leaderboard_includes_avatar_url(client: TestClient, monkeypatch, tmp_path): def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path) _use_tmp_uploads(monkeypatch, tmp_path)
me = login(client, "Топчик") me = login(client, "Топчик")
_finished_match_for(client, me) _finished_match_for(client, engine, me)
def entry_for(uid: int) -> dict: def entry_for(uid: int) -> dict:
board = client.get("/api/stats/leaderboard").json() board = client.get("/api/stats/leaderboard").json()
+1
View File
@@ -15,6 +15,7 @@ export const qk = {
groupStats: (id: number) => ["group", id, "stats"] as const, groupStats: (id: number) => ["group", id, "stats"] as const,
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,
devUsers: ["devUsers"] as const, devUsers: ["devUsers"] as const,
adminUsers: ["adminUsers"] as const, adminUsers: ["adminUsers"] as const,
adminGroups: ["adminGroups"] as const, adminGroups: ["adminGroups"] as const,
+166 -4
View File
@@ -305,8 +305,24 @@ export interface paths {
/** List Members */ /** List Members */
get: operations["list_members_api_groups__group_id__members_get"]; get: operations["list_members_api_groups__group_id__members_get"];
put?: never; put?: never;
/** Add Member */ post?: never;
post: operations["add_member_api_groups__group_id__members_post"]; delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/groups/{group_id}/invitations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Invite Member */
post: operations["invite_member_api_groups__group_id__invitations_post"];
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -365,6 +381,57 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/invitations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** My Invitations */
get: operations["my_invitations_api_invitations_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/invitations/{invitation_id}/accept": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Accept Invitation */
post: operations["accept_invitation_api_invitations__invitation_id__accept_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/invitations/{invitation_id}/decline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Decline Invitation */
post: operations["decline_invitation_api_invitations__invitation_id__decline_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/matches/randomize-faction": { "/api/matches/randomize-faction": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1272,6 +1339,19 @@ export interface components {
/** Min Games */ /** Min Games */
min_games: number; min_games: number;
}; };
/** InvitationRead */
InvitationRead: {
/** Id */
id: number;
/** Group Id */
group_id: number;
/** Group Name */
group_name: string;
/** Invited By Nickname */
invited_by_nickname?: string | null;
/** Created At */
created_at: string;
};
/** Leaderboard */ /** Leaderboard */
Leaderboard: { Leaderboard: {
/** /**
@@ -2317,7 +2397,7 @@ export interface operations {
}; };
}; };
}; };
add_member_api_groups__group_id__members_post: { invite_member_api_groups__group_id__invitations_post: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -2338,7 +2418,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["MemberRead"]; "application/json": components["schemas"]["InvitationRead"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@@ -2485,6 +2565,88 @@ export interface operations {
}; };
}; };
}; };
my_invitations_api_invitations_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"]["InvitationRead"][];
};
};
};
};
accept_invitation_api_invitations__invitation_id__accept_post: {
parameters: {
query?: never;
header?: never;
path: {
invitation_id: number;
};
cookie?: never;
};
requestBody?: never;
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"];
};
};
};
};
decline_invitation_api_invitations__invitation_id__decline_post: {
parameters: {
query?: never;
header?: never;
path: {
invitation_id: number;
};
cookie?: never;
};
requestBody?: never;
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"];
};
};
};
};
randomize_faction_api_matches_randomize_faction_post: { randomize_faction_api_matches_randomize_faction_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -0,0 +1,58 @@
import { ApiError } from "../api/client";
import { useToast } from "../context/ToastContext";
import { useAcceptInvitation, useDeclineInvitation, useMyInvitations } from "../hooks/invitations";
export function GroupInvitations() {
const { data: invitations } = useMyInvitations();
const accept = useAcceptInvitation();
const decline = useDeclineInvitation();
const toast = useToast();
if (!invitations || invitations.length === 0) return null;
const onAccept = async (id: number) => {
try {
await accept.mutateAsync(id);
toast.show("Вы вступили в группу");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const onDecline = async (id: number) => {
try {
await decline.mutateAsync(id);
toast.show("Приглашение отклонено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const busy = accept.isPending || decline.isPending;
return (
<div className="card">
<h3>Приглашения в группы</h3>
<div className="stack">
{invitations.map((inv) => (
<div key={inv.id} className="row-between">
<div>
<b>{inv.group_name}</b>
{inv.invited_by_nickname && (
<div className="muted small">пригласил: {inv.invited_by_nickname}</div>
)}
</div>
<div className="row" style={{ gap: 6 }}>
<button className="btn btn-primary small" onClick={() => onAccept(inv.id)} disabled={busy}>
Согласиться
</button>
<button className="btn btn-ghost small" onClick={() => onDecline(inv.id)} disabled={busy}>
Отказаться
</button>
</div>
</div>
))}
</div>
</div>
);
}
+1
View File
@@ -9,6 +9,7 @@ export type GroupDetail = S["GroupDetail"];
export type ExpansionRead = S["ExpansionRead"]; export type ExpansionRead = S["ExpansionRead"];
export type FactionRead = S["FactionRead"]; export type FactionRead = S["FactionRead"];
export type MemberRead = S["MemberRead"]; export type MemberRead = S["MemberRead"];
export type InvitationRead = S["InvitationRead"];
export type MatchRead = S["MatchRead"]; export type MatchRead = S["MatchRead"];
export type MatchList = S["MatchList"]; export type MatchList = S["MatchList"];
export type MatchListItem = S["MatchListItem"]; export type MatchListItem = S["MatchListItem"];
+3 -4
View File
@@ -92,17 +92,16 @@ export function useSetGroupExpansions(groupId: number) {
}); });
} }
export function useAddMember(groupId: number) { // Приглашение игрока в группу по нику (вместо прямого добавления).
const qc = useQueryClient(); export function useInviteMember(groupId: number) {
return useMutation({ return useMutation({
mutationFn: async (nickname: string) => mutationFn: async (nickname: string) =>
unwrap( unwrap(
await api.POST("/api/groups/{group_id}/members", { await api.POST("/api/groups/{group_id}/invitations", {
params: { path: { group_id: groupId } }, params: { path: { group_id: groupId } },
body: { nickname }, body: { nickname },
}), }),
), ),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) }),
}); });
} }
+45
View File
@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
export function useMyInvitations() {
return useQuery({
queryKey: qk.invitations,
queryFn: async () => unwrap(await api.GET("/api/invitations")),
});
}
// Принятие/отклонение меняет приглашения, состав групп и активную группу.
function invalidateAll(qc: ReturnType<typeof useQueryClient>) {
qc.invalidateQueries({ queryKey: qk.invitations });
qc.invalidateQueries({ queryKey: qk.me });
qc.invalidateQueries({ queryKey: qk.groups });
qc.invalidateQueries({ queryKey: qk.home });
}
export function useAcceptInvitation() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (invitationId: number) =>
unwrap(
await api.POST("/api/invitations/{invitation_id}/accept", {
params: { path: { invitation_id: invitationId } },
}),
),
onSuccess: () => invalidateAll(qc),
});
}
export function useDeclineInvitation() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (invitationId: number) =>
unwrap(
await api.POST("/api/invitations/{invitation_id}/decline", {
params: { path: { invitation_id: invitationId } },
}),
),
onSuccess: () => invalidateAll(qc),
});
}
+5 -1
View File
@@ -1,6 +1,7 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateGroupForm } from "../components/CreateGroupForm"; import { CreateGroupForm } from "../components/CreateGroupForm";
import { GroupInvitations } from "../components/GroupInvitations";
import { GroupSwitcher } from "../components/GroupSwitcher"; import { GroupSwitcher } from "../components/GroupSwitcher";
import { Leaderboard } from "../components/Leaderboard"; import { Leaderboard } from "../components/Leaderboard";
import { MatchListView } from "../components/MatchList"; import { MatchListView } from "../components/MatchList";
@@ -25,9 +26,10 @@ export function GroupPage() {
<div className="card"> <div className="card">
<h3>Активная группа не выбрана</h3> <h3>Активная группа не выбрана</h3>
<p className="muted small"> <p className="muted small">
Создайте группу или выберите существующую, чтобы вести учёт партий. Создайте группу, выберите существующую или примите приглашение, чтобы вести учёт партий.
</p> </p>
</div> </div>
<GroupInvitations />
<GroupSwitcher /> <GroupSwitcher />
<CreateGroupForm /> <CreateGroupForm />
</div> </div>
@@ -51,6 +53,8 @@ export function GroupPage() {
</p> </p>
</div> </div>
<GroupInvitations />
<div className="card"> <div className="card">
<h3>Топ в группе</h3> <h3>Топ в группе</h3>
{stats && <Leaderboard entries={stats.leaderboard} provisional={stats.provisional} />} {stats && <Leaderboard entries={stats.leaderboard} provisional={stats.provisional} />}
+10 -6
View File
@@ -6,7 +6,7 @@ import { Spinner } from "../components/Spinner";
import { MAX_GROUP_SIZE } from "../domain/constants"; import { MAX_GROUP_SIZE } from "../domain/constants";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth"; import { useMe } from "../hooks/auth";
import { useAddMember, useGroup, useGroupMembers, useRemoveMember, useSetGroupExpansions } from "../hooks/groups"; import { useGroup, useGroupMembers, useInviteMember, useRemoveMember, useSetGroupExpansions } from "../hooks/groups";
import { useExpansions } from "../hooks/reference"; import { useExpansions } from "../hooks/reference";
export function GroupSettingsPage() { export function GroupSettingsPage() {
@@ -16,7 +16,7 @@ export function GroupSettingsPage() {
const { data: expansions } = useExpansions(); const { data: expansions } = useExpansions();
const { data: members } = useGroupMembers(groupId); const { data: members } = useGroupMembers(groupId);
const setExpansions = useSetGroupExpansions(groupId ?? 0); const setExpansions = useSetGroupExpansions(groupId ?? 0);
const addMember = useAddMember(groupId ?? 0); const inviteMember = useInviteMember(groupId ?? 0);
const removeMember = useRemoveMember(groupId ?? 0); const removeMember = useRemoveMember(groupId ?? 0);
const toast = useToast(); const toast = useToast();
@@ -51,9 +51,9 @@ export function GroupSettingsPage() {
const nick = newNick.trim(); const nick = newNick.trim();
if (!nick) return; if (!nick) return;
try { try {
await addMember.mutateAsync(nick); await inviteMember.mutateAsync(nick);
setNewNick(""); setNewNick("");
toast.show("Игрок добавлен"); toast.show("Приглашение отправлено");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.show(e instanceof ApiError ? e.message : "Ошибка");
} }
@@ -102,8 +102,12 @@ export function GroupSettingsPage() {
)} )}
{isOwner && (members ?? []).length < MAX_GROUP_SIZE && ( {isOwner && (members ?? []).length < MAX_GROUP_SIZE && (
<div className="row" style={{ marginBottom: 10 }}> <div className="row" style={{ marginBottom: 10 }}>
<input placeholder="Никнейм игрока" value={newNick} onChange={(e) => setNewNick(e.target.value)} /> <input
<button className="btn btn-primary" onClick={add}> placeholder="Ник игрока — отправить приглашение"
value={newNick}
onChange={(e) => setNewNick(e.target.value)}
/>
<button className="btn btn-primary" onClick={add} disabled={inviteMember.isPending}>
<UserPlus size={18} /> <UserPlus size={18} />
</button> </button>
</div> </div>