diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f4ce90b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Python +**/__pycache__/ +**/*.pyc +backend/.venv/ +backend/.pytest_cache/ +backend/*.db +backend/*.db-wal +backend/*.db-shm +backend/openapi.json + +# Node / сборка фронта +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# DEV-вход (по нику) физически НЕ попадает в прод-образ +backend/app/auth/dev_stub.py +backend/app/routers/dev_auth.py + +# Тесты и dev-манифест зависимостей в прод-образе не нужны (ставим из requirements.txt) +backend/tests/ +backend/pyproject.toml + +# Прочее +.env +data/ +**/.DS_Store +*.md +.claude/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8225661 --- /dev/null +++ b/.env.example @@ -0,0 +1,55 @@ +# ╔═══════════════════════════════════════════════════════════════════════════╗ +# ║ Forbidden Stars — единый .env (dev и prod) ║ +# ║ Скопируйте в `.env`, заполните секреты. Реальный `.env` в git НЕ идёт. ║ +# ║ Переключение dev/prod — одной строкой APP_ENV (ниже). ║ +# ╚═══════════════════════════════════════════════════════════════════════════╝ + +# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ──────────────────────────────────────────────────── +# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать: +# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник +# test — прод-клон в Docker локально (порт 8080), вход только TG +# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d). +# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production. +APP_ENV=development + +# ─── АДМИНИСТРАТОР (вход по логину/паролю, отдельно от Telegram) ────────────── +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-admin-password +ADMIN_BOOTSTRAP_ENABLED=true + +# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ─────────────────────────────────────────────────── +# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только +# Telegram. Отдельного переключателя нет. Для Telegram нужны токен и юзернейм бота +# (@BotFather) и /setdomain на ваш HTTPS-домен. +TELEGRAM_BOT_TOKEN= +TELEGRAM_BOT_USERNAME= +PUBLIC_BASE_URL= + +# ─── БЕЗОПАСНОСТЬ / СЕССИИ ──────────────────────────────────────────────────── +# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))" +# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как +# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно). +SECRET_KEY=change-me-dev-secret-not-for-production +JWT_ALGORITHM=HS256 +JWT_USER_TTL_MINUTES=10080 +JWT_ADMIN_TTL_MINUTES=480 +# dev/test (локально, по HTTP) → false; production за HTTPS на Pi → true. +COOKIE_SECURE=false +COOKIE_DOMAIN= + +# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ──────────── +# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL +# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml). +DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db +PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db + +# Каталог загрузок (зарезервировано под фото/видео), тоже раздельно. +DEV_UPLOAD_DIR=./data/dev/uploads +PROD_UPLOAD_DIR=/data/uploads + +# ─── ПРОЧЕЕ ─────────────────────────────────────────────────────────────────── +# Часовой пояс приложения (фикс. смещение в часах; МСК = 3) +APP_TZ_OFFSET_HOURS=3 +# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 +LOG_LEVEL=INFO diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..716f18d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# ── Нормализация переводов строк ────────────────────────────────────────────── +# Шелл-скрипты обязаны быть с LF: в Linux-контейнере и на Pi CRLF ломает shebang. +# entrypoint.sh контейнер чинит сам (sed в Dockerfile), но backup.sh запускается +# с хоста Pi — для него LF в репозитории критичен. +*.sh text eol=lf +backend/entrypoint.sh text eol=lf +scripts/backup.sh text eol=lf + +# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ───── +# В git эти файлы есть и доступны на всех ветках (нужны для разработки), +# но в архив деплоя (scripts/export-*.sh) не идут. На Docker-сборку НЕ влияет — +# там чистоту образа обеспечивает .dockerignore. +backend/tests/ export-ignore +backend/app/auth/dev_stub.py export-ignore +backend/app/routers/dev_auth.py export-ignore +backend/pyproject.toml export-ignore +README.md export-ignore +.gitignore export-ignore +.gitattributes export-ignore +.dockerignore export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9c8e26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Базы данных / данные (dev-БД лежит в backend/data/dev/) +*.db +*.db-wal +*.db-shm +data/ +backend/dev.db* + +# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod) +.env +!.env.example + +# Node / фронт +node_modules/ +frontend/dist/ +frontend/.vite/ +*.tsbuildinfo + +# Сгенерированный снапшот OpenAPI (контракт фронта закоммичен в schema.d.ts) +backend/openapi.json + +# Бэкапы (создаёт scripts/backup.sh на Pi) +backups/ + +# Редактор / ОС +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8dc3137 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# ─── Этап 1: сборка SPA ─────────────────────────────────────────────────────── +FROM node:20-bookworm-slim AS frontend +WORKDIR /app/frontend + +# Сначала зависимости (кеш слоёв). Используем lock-файл, если он есть. +COPY frontend/package*.json ./ +RUN npm install --no-audit --no-fund + +COPY frontend/ ./ +# Типы schema.d.ts уже в репозитории — бэкенд для сборки не нужен. +RUN npm run build + + +# ─── Этап 2: рантайм FastAPI (ARM64-совместимый glibc-образ) ────────────────── +FROM python:3.12-slim-bookworm AS runtime +# Контейнер — всегда production. БД/загрузки выбираются по APP_ENV (см. config.py): +# prod → /data на томе. SPA отдаётся из /app/static. +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + APP_ENV=production \ + STATIC_DIR=/app/static + +# tini — корректная обработка сигналов (чек-пойнт WAL при остановке). +RUN apt-get update \ + && apt-get install -y --no-install-recommends tini \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY backend/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ /app/ +COPY --from=frontend /app/frontend/dist /app/static + +# Непривилегированный пользователь + том данных + исполняемый entrypoint (LF). +RUN sed -i 's/\r$//' /app/entrypoint.sh \ + && chmod +x /app/entrypoint.sh \ + && useradd --create-home --uid 10001 appuser \ + && mkdir -p /data/uploads \ + && chown -R appuser:appuser /app /data +USER appuser + +EXPOSE 8000 +ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"] diff --git a/README.md b/README.md index 42188aa..43ffe95 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,23 @@ docker-compose.yml ## Локальная разработка -Бэкенд и фронт запускаются раздельно; Vite проксирует `/api` на бэкенд. +### Единый лаунчер (`run.ps1` / `run.sh`) + +После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно, +решает `APP_ENV` в корневом `.env`: + +```powershell +.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh) +``` + +| `APP_ENV` в `.env` | что делает лаунчер | +|---|---| +| `development` | `uvicorn --reload` (бэк) + `vite` (фронт) нативно, в двух окнах | +| `test` | `docker compose` прод-клон на :8080 (со сборкой образа) | +| `production` | не запускает — прод деплоится отдельно (см. «Git и деплой») | + +Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта +(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд. ### 1) Бэкенд @@ -91,32 +107,37 @@ FastAPI отдаёт собранный SPA и API с одного origin. Ми только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi. Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000). +Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`. +Вручную (тот же эффект): ```bash -cp .env.test.example .env.test # заполнить при необходимости -docker compose --env-file .env.test -f docker-compose.test.yml up -d --build +docker compose -f docker-compose.test.yml up -d --build # открыть http://localhost:8080 (Swagger: /api/docs) docker compose -f docker-compose.test.yml down -v # остановить и стереть тестовые данные ``` -- Окружение принудительно `production` (клон Pi), но `COOKIE_SECURE=false` (локально HTTP). +- Окружение `test` (прод-клон), но `COOKIE_SECURE=false` (локально по HTTP). +- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри + контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`). - Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi). - Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; вход **игроков** — только через Telegram (нужен бот + публичный HTTPS/туннель на `localhost:8080`). -- Если пароль/секрет содержит `$`, для docker compose экранируйте его как `$$` - (для dev-uvicorn экранирование не нужно — pydantic читает `$` дословно). +- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), + и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало + везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так: + `python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`). ## Аутентификация Методы входа зависят от окружения (`APP_ENV`): -| | dev | prod | +| | dev | test / prod | |---|---|---| | Telegram Login Widget | ✓ | ✓ (единственный) | | Вход по нику (stub) | ✓ | ✗ (физически отсутствует) | - **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:** файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из - Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV != production` + Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development` (`app/main.py`), а на фронте dev-блок вырезается из прод-сборки (`import.meta.env.DEV`). - **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`). `GET /api/auth/config` отдаёт доступные методы и `telegram_bot_username` для виджета. @@ -127,29 +148,41 @@ docker compose -f docker-compose.test.yml down -v # остановить и с 3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`). 4. Домену нужен HTTPS (например, Cloudflare Tunnel) — виджет не работает по голому HTTP. -Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен в обоих окружениях. +Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях. ## Окружения (dev / test / prod) -Один и тот же код; контур выбирается тем, **чем и с каким `.env` запускаешь**: +Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер): | | dev | test (прод-клон локально) | prod (Pi) | |---|---|---|---| -| Запуск | `uvicorn --reload` + `vite` | `docker compose -f docker-compose.test.yml` | `docker compose` | -| Env-файл | `.env` (корень) | `.env.test` (корень) | `.env` (корень, на Pi) | -| `APP_ENV` | `development` | `production` (форсится) | `production` (форсится) | +| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` | +| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) | +| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) | | Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 | | База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) | | Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram | -- **Структура БД одна** (общие миграции Alembic), **файлы разные** — путь выбирается по - `APP_ENV` (`DEV_DATABASE_URL` / `PROD_DATABASE_URL`). Данные дева — в `backend/data/dev/` - (в образ **не попадают**: `data/` в `.dockerignore`). -- **В Docker идёт только прод-код:** контейнер принудительно `APP_ENV=production`, dev-вход - (stub) физически исключён из образа. `test` — это тот же прод-образ, просто локально. -- **`.env` один на машину**, в корне (рядом с `.env.example`). `test` использует свой - `.env.test` (чтобы крутить прод-клон рядом с dev, не мешая ему). Реальные `.env`/`.env.test` - хранятся только локально; рядом лежат шаблоны `*.example`. +- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что + запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда + `production`. Отдельного `.env.test` больше нет. +- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL` + (`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это + РАЗНЫЕ тома). Данные дева в образ **не попадают** (`data/` в `.dockerignore`). +- **В Docker идёт только прод-код:** dev-вход (stub) и тесты физически исключены из образа + (`.dockerignore`); `test` — тот же образ, что и прод, просто локально и с `APP_ENV=test`. + +## Git и деплой + +- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь. +- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во + всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный; + чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов. +- **Деплой на Pi:** `git pull` ветки `main` → `docker compose up -d --build`. +- **Чистая выгрузка в папку без git** (опц.): `scripts/export-prod.sh main` (через + `git archive` + `export-ignore` — в папку идёт ровно прод-набор, без лаунчера/тестов/dev-входа). + +Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`. ## Дополнения и фракции diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..aa1666e --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,40 @@ +# Конфигурация Alembic. URL берётся из настроек приложения (env.py), здесь — заглушка. +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = sqlite:///./dev.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..ea7bea7 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,55 @@ +"""Окружение Alembic. URL берётся из настроек, metadata — из SQLModel.""" +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +from app.core.config import settings + +# Регистрируем все модели в SQLModel.metadata. +import app.models # noqa: F401 + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = SQLModel.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, # обязательно для SQLite (ALTER через copy-and-recreate) + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fdd99a1 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py new file mode 100644 index 0000000..04b7395 --- /dev/null +++ b/backend/alembic/versions/0001_initial.py @@ -0,0 +1,32 @@ +"""Начальная схема (все таблицы v1). + +Создаём схему из метаданных SQLModel — гарантирует точное соответствие моделям +для greenfield-проекта. Последующие изменения схемы — обычными op.* миграциями. + +Revision ID: 0001_initial +Revises: +Create Date: 2026-06-15 +""" +from typing import Sequence, Union + +from sqlmodel import SQLModel + +from alembic import op + +# Импорт моделей наполняет SQLModel.metadata. +import app.models # noqa: F401 + +revision: str = "0001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + SQLModel.metadata.create_all(bind) + + +def downgrade() -> None: + bind = op.get_bind() + SQLModel.metadata.drop_all(bind) diff --git a/backend/alembic/versions/0002_seed_reference.py b/backend/alembic/versions/0002_seed_reference.py new file mode 100644 index 0000000..1161109 --- /dev/null +++ b/backend/alembic/versions/0002_seed_reference.py @@ -0,0 +1,30 @@ +"""Сидинг справочников: дополнения и фракции (идемпотентно). + +Revision ID: 0002_seed_reference +Revises: 0001_initial +Create Date: 2026-06-15 +""" +from typing import Sequence, Union + +from sqlmodel import Session + +from alembic import op +from app.seed.reference_data import seed_reference_data + +revision: str = "0002_seed_reference" +down_revision: Union[str, None] = "0001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + with Session(bind=bind) as session: + seed_reference_data(session) + + +def downgrade() -> None: + bind = op.get_bind() + op.execute("DELETE FROM factions") + op.execute("DELETE FROM expansions") + _ = bind diff --git a/backend/alembic/versions/0003_match_lifecycle.py b/backend/alembic/versions/0003_match_lifecycle.py new file mode 100644 index 0000000..0503e2c --- /dev/null +++ b/backend/alembic/versions/0003_match_lifecycle.py @@ -0,0 +1,63 @@ +"""Жизненный цикл партии: статус, тайминг, длительность, причина победы; place → nullable. + +Идемпотентна: на свежей БД (0001 создаёт схему из актуальных моделей через create_all) +столбцы уже есть → no-op; на существующей БД (ревизия 0002, старая схема) — добавляет +столбцы и делает place nullable. render_as_batch включён в env.py (нужен для SQLite). + +Revision ID: 0003_match_lifecycle +Revises: 0002_seed_reference +Create Date: 2026-06-15 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0003_match_lifecycle" +down_revision: Union[str, None] = "0002_seed_reference" +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) + + match_cols = {c["name"] for c in insp.get_columns("matches")} + add = [] + if "status" not in match_cols: + add.append(sa.Column("status", sa.String(16), nullable=False, server_default="finished")) + if "started_at" not in match_cols: + add.append(sa.Column("started_at", sa.DateTime(), nullable=True)) + if "finished_at" not in match_cols: + add.append(sa.Column("finished_at", sa.DateTime(), nullable=True)) + if "duration_minutes" not in match_cols: + add.append(sa.Column("duration_minutes", sa.Integer(), nullable=True)) + if "win_reason" not in match_cols: + add.append(sa.Column("win_reason", sa.String(16), nullable=True)) + + if add: + with op.batch_alter_table("matches") as b: + for col in add: + b.add_column(col) + # Бэкфилл для существующих (уже завершённых) партий. + op.execute("UPDATE matches SET status = 'finished' WHERE status IS NULL") + op.execute("UPDATE matches SET started_at = created_at WHERE started_at IS NULL") + op.execute("UPDATE matches SET finished_at = created_at WHERE finished_at IS NULL") + + # place → nullable, если ещё NOT NULL. + place_col = next(c for c in insp.get_columns("match_participants") if c["name"] == "place") + if not place_col["nullable"]: + with op.batch_alter_table("match_participants") as b: + b.alter_column("place", existing_type=sa.Integer(), nullable=True) + + +def downgrade() -> None: + with op.batch_alter_table("matches") as b: + for name in ("win_reason", "duration_minutes", "finished_at", "started_at", "status"): + try: + b.drop_column(name) + except Exception: # noqa: BLE001 + pass diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py new file mode 100644 index 0000000..541fbd2 --- /dev/null +++ b/backend/app/auth/deps.py @@ -0,0 +1,43 @@ +"""FastAPI-зависимости аутентификации и авторизации.""" +from __future__ import annotations + +import jwt +from fastapi import Depends, Request +from sqlmodel import Session + +from app.core import security +from app.core.errors import AuthError, ForbiddenError +from app.db.session import get_session +from app.models import User + + +def get_current_user( + request: Request, session: Session = Depends(get_session) +) -> User: + token = request.cookies.get(security.USER_COOKIE) + if not token: + raise AuthError("Требуется вход.") + try: + payload = security.decode_token(token, security.AUDIENCE_USER) + except jwt.PyJWTError as exc: # noqa: F841 + raise AuthError("Сессия недействительна.") + user = session.get(User, int(payload["sub"])) + if user is None or not user.is_active: + raise AuthError("Сессия недействительна.") + return user + + +def get_current_admin( + request: Request, session: Session = Depends(get_session) +) -> User: + token = request.cookies.get(security.ADMIN_COOKIE) + if not token: + raise AuthError("Требуется вход администратора.") + try: + payload = security.decode_token(token, security.AUDIENCE_ADMIN) + except jwt.PyJWTError: + raise AuthError("Сессия администратора недействительна.") + user = session.get(User, int(payload["sub"])) + if user is None or user.role != "admin" or not user.is_active: + raise ForbiddenError("Доступ только для администратора.") + return user diff --git a/backend/app/auth/dev_stub.py b/backend/app/auth/dev_stub.py new file mode 100644 index 0000000..8dc37fc --- /dev/null +++ b/backend/app/auth/dev_stub.py @@ -0,0 +1,23 @@ +"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production).""" +from __future__ import annotations + +from typing import Any + +from app.auth.provider import AuthProvider, ExternalIdentity +from app.core.errors import ValidationError + + +class DevStubProvider(AuthProvider): + name = "stub" + + def authenticate(self, payload: dict[str, Any]) -> ExternalIdentity: + nickname = (payload.get("nickname") or "").strip() + external_id = str(payload.get("external_id") or nickname).strip() + if not external_id: + raise ValidationError("Укажите никнейм для входа.") + return ExternalIdentity( + provider=self.name, + external_id=external_id, + suggested_nickname=nickname or external_id, + raw=dict(payload), + ) diff --git a/backend/app/auth/login.py b/backend/app/auth/login.py new file mode 100644 index 0000000..9a54a6e --- /dev/null +++ b/backend/app/auth/login.py @@ -0,0 +1,36 @@ +"""Общий вход: внешняя личность → пользователь → сессия. + +Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом, +и dev-входом. +""" +from __future__ import annotations + +from fastapi import Request, Response +from sqlmodel import Session + +from app.auth.provider import ExternalIdentity +from app.core import security +from app.core.errors import ForbiddenError +from app.models import User +from app.services import audit_service, user_service + + +def login_with_identity( + session: Session, response: Response, request: Request, identity: ExternalIdentity +) -> User: + user = user_service.get_or_create_from_identity(session, identity) + if not user.is_active: + raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED") + security.set_user_session(response, user.id, identity.provider) # type: ignore[arg-type] + audit_service.record( + session, + actor_id=user.id, + action="login", + entity_type="user", + entity_id=user.id, + payload={"provider": identity.provider}, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return user diff --git a/backend/app/auth/provider.py b/backend/app/auth/provider.py new file mode 100644 index 0000000..5287faa --- /dev/null +++ b/backend/app/auth/provider.py @@ -0,0 +1,26 @@ +"""Абстракция провайдера аутентификации (pluggable).""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ExternalIdentity: + """Нормализованная личность от внешнего провайдера.""" + + provider: str + external_id: str + suggested_nickname: str | None = None + telegram_id: int | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +class AuthProvider(ABC): + name: str + + @abstractmethod + def authenticate(self, payload: dict[str, Any]) -> ExternalIdentity: + """Проверяет вход и возвращает внешнюю личность. Бросает AppError при ошибке.""" + raise NotImplementedError diff --git a/backend/app/auth/registry.py b/backend/app/auth/registry.py new file mode 100644 index 0000000..952782a --- /dev/null +++ b/backend/app/auth/registry.py @@ -0,0 +1,15 @@ +"""Доступные методы входа по окружению. + +Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG). +Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода. +""" +from __future__ import annotations + +from app.core.config import settings + + +def enabled_methods() -> list[str]: + methods = ["telegram"] + if settings.is_development: + methods.append("stub") + return methods diff --git a/backend/app/auth/telegram.py b/backend/app/auth/telegram.py new file mode 100644 index 0000000..806f196 --- /dev/null +++ b/backend/app/auth/telegram.py @@ -0,0 +1,59 @@ +"""Telegram Login Widget провайдер. + +Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть +auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте). +Доступен и в dev, и в prod (в prod — единственный метод входа). +""" +from __future__ import annotations + +import hashlib +import hmac +import time +from typing import Any + +from app.auth.provider import AuthProvider, ExternalIdentity +from app.core.config import settings +from app.core.errors import AuthError + +_MAX_AUTH_AGE_SECONDS = 86400 # сутки + + +class TelegramProvider(AuthProvider): + name = "telegram" + + def authenticate(self, payload: dict[str, Any]) -> ExternalIdentity: + token = settings.telegram_bot_token + if not token: + raise AuthError("Telegram-вход не настроен (нет TELEGRAM_BOT_TOKEN).") + + data = {k: v for k, v in payload.items() if k != "hash" and v is not None} + received_hash = payload.get("hash") + if not received_hash: + raise AuthError("Отсутствует подпись Telegram.") + + check_string = "\n".join(f"{k}={data[k]}" for k in sorted(data)) + secret_key = hashlib.sha256(token.encode("utf-8")).digest() + computed = hmac.new( + secret_key, check_string.encode("utf-8"), hashlib.sha256 + ).hexdigest() + + if not hmac.compare_digest(computed, str(received_hash)): + raise AuthError("Подпись Telegram не прошла проверку.") + + auth_date = int(data.get("auth_date", 0)) + if auth_date and time.time() - auth_date > _MAX_AUTH_AGE_SECONDS: + raise AuthError("Срок действия данных Telegram истёк.") + + tg_id = int(data["id"]) + username = data.get("username") + first = data.get("first_name", "") + last = data.get("last_name", "") + suggested = username or (f"{first} {last}".strip()) or str(tg_id) + + return ExternalIdentity( + provider=self.name, + external_id=str(tg_id), + suggested_nickname=suggested, + telegram_id=tg_id, + raw=dict(payload), + ) diff --git a/backend/app/bootstrap.py b/backend/app/bootstrap.py new file mode 100644 index 0000000..5a771a0 --- /dev/null +++ b/backend/app/bootstrap.py @@ -0,0 +1,65 @@ +"""Идемпотентный бутстрап: справочники + учётная запись администратора. + +Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций). +""" +from __future__ import annotations + +from sqlmodel import Session, select + +from app.core.config import settings +from app.core.security import hash_password, verify_password +from app.db.session import engine +from app.models import User +from app.seed.reference_data import seed_reference_data + + +def _ensure_admin(session: Session) -> None: + if not settings.admin_bootstrap_enabled: + return + password = (settings.admin_password or "").strip() + username = (settings.admin_username or "admin").strip() + existing = session.exec(select(User).where(User.role == "admin")).first() + + if existing is None: + if not password: + raise RuntimeError("Отказ создавать администратора без пароля (ADMIN_PASSWORD пуст).") + # Логин администратора = его никнейм (отдельной таблицы админов нет). + session.add( + User( + nickname=username, + role="admin", + auth_provider="local", + password_hash=hash_password(password), + ) + ) + session.commit() + print(f"[bootstrap] Создан администратор: {username}") + return + + # Администратор уже существует. + if not settings.is_development: + # В test/prod пароль НЕ перезаписываем (мог быть изменён через панель). + return + + # DEV: подтягиваем логин/пароль из .env (env — источник истины в деве). + changed: list[str] = [] + if password and not verify_password(password, existing.password_hash or ""): + existing.password_hash = hash_password(password) + changed.append("пароль") + if username and existing.nickname != username: + existing.nickname = username + changed.append("логин") + if changed: + session.add(existing) + session.commit() + print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})") + + +def bootstrap() -> None: + with Session(engine) as session: + seed_reference_data(session) # идемпотентно + _ensure_admin(session) + + +if __name__ == "__main__": + bootstrap() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..94113fd --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,105 @@ +"""Конфигурация приложения из переменных окружения (12-factor).""" +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково +# и на деве (uvicorn из backend/), и где бы ни была рабочая папка. В Docker файла нет +# (исключён из образа) — там настройки приходят переменными от docker compose. +_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env") + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=_ROOT_ENV, + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + # ── Главный переключатель окружения: development | test | production ─────── + # development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник. + # test — прод-клон в Docker локально (порт 8080), ведёт себя как прод. + # production — Docker на Pi; контейнер форсит это значение, игнорируя .env. + app_env: str = "development" + log_level: str = "INFO" + + # Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). + # Хранение всегда в UTC; смещение применяется к «дате игры» и отображению. + app_tz_offset_hours: int = 3 + + # ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ───── + # dev — в папке данных дева; prod — на постоянном томе контейнера. + dev_database_url: str = "sqlite:///./data/dev/forbidden_stars.db" + prod_database_url: str = "sqlite:////data/forbidden_stars.db" + + # Каталог загрузок (зарезервировано под вложения), тоже раздельно. + dev_upload_dir: str = "./data/dev/uploads" + prod_upload_dir: str = "/data/uploads" + + # JWT / cookie + secret_key: str = "change-me-dev-secret-not-for-production" + jwt_algorithm: str = "HS256" + jwt_user_ttl_minutes: int = 60 * 24 * 7 + jwt_admin_ttl_minutes: int = 60 * 8 + cookie_secure: bool = False + cookie_domain: str | None = None + + # Аутентификация. Методы входа определяются окружением (dev: telegram+stub, + # prod: только telegram) — отдельного переключателя провайдера нет. + telegram_bot_token: str | None = None + telegram_bot_username: str | None = None + public_base_url: str | None = None + + # Бутстрап администратора + admin_bootstrap_enabled: bool = True + admin_username: str = "admin" + admin_password: str = "change-me-admin-password" + admin_nickname: str = "Администратор" + + # CORS (для раздельного dev-режима фронта). Строка из env, через запятую — + # храним как str и режем в свойстве, чтобы pydantic-settings не пытался + # распарсить значение как JSON (иначе "http://..." ломает разбор .env). + cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173" + + @property + def cors_origins_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + @property + def is_development(self) -> bool: + """Нативная разработка. Только это окружение включает dev-вход (stub), + стартовый bootstrap в lifespan и синхронизацию админа из .env.""" + return self.app_env.lower() == "development" + + @property + def is_test(self) -> bool: + return self.app_env.lower() == "test" + + @property + def is_production(self) -> bool: + return self.app_env.lower() == "production" + + @property + def database_url(self) -> str: + """БД: dev — отдельный файл дева; test и prod — том контейнера (/data).""" + return self.dev_database_url if self.is_development else self.prod_database_url + + @property + def upload_dir(self) -> str: + return self.dev_upload_dir if self.is_development else self.prod_upload_dir + + @property + def cookie_domain_value(self) -> str | None: + return self.cookie_domain or None + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..5541f74 --- /dev/null +++ b/backend/app/core/errors.py @@ -0,0 +1,126 @@ +"""Доменные исключения и единый конверт ошибок API. + +Формат ответа: {"error": {"code": "...", "message": "...", "details": {...}}} +""" +from __future__ import annotations + +from typing import Any + +from fastapi import Request +from fastapi.responses import JSONResponse + + +class AppError(Exception): + """Базовая ошибка приложения со стабильным машинным кодом.""" + + status_code: int = 400 + code: str = "BAD_REQUEST" + + def __init__( + self, + message: str, + *, + code: str | None = None, + status_code: int | None = None, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.message = message + if code is not None: + self.code = code + if status_code is not None: + self.status_code = status_code + self.details = details + + def to_response(self) -> JSONResponse: + return JSONResponse( + status_code=self.status_code, + content={ + "error": { + "code": self.code, + "message": self.message, + "details": self.details, + } + }, + ) + + +class NotFoundError(AppError): + status_code = 404 + code = "NOT_FOUND" + + +class ForbiddenError(AppError): + status_code = 403 + code = "FORBIDDEN" + + +class ValidationError(AppError): + status_code = 422 + code = "VALIDATION_ERROR" + + +class ConflictError(AppError): + status_code = 409 + code = "CONFLICT" + + +class AuthError(AppError): + status_code = 401 + code = "UNAUTHORIZED" + + +# ─── Конкретные ошибки со стабильными кодами для фронта ─────────────────────── + +class NoGroupError(AppError): + status_code = 409 + code = "NO_GROUP" + + def __init__(self) -> None: + super().__init__("Вы не состоите ни в одной группе.") + + +class NotGroupMemberError(ForbiddenError): + code = "NOT_GROUP_MEMBER" + + def __init__(self) -> None: + super().__init__("Вы не являетесь участником этой группы.") + + +class NicknameTakenError(ConflictError): + code = "NICKNAME_TAKEN" + + def __init__(self) -> None: + super().__init__("Никнейм уже занят.") + + +class FactionNotAvailableError(ValidationError): + code = "FACTION_NOT_AVAILABLE" + + def __init__(self) -> None: + super().__init__("Фракция недоступна выбранной группе.") + + +class DuplicateParticipantError(ValidationError): + code = "DUPLICATE_PARTICIPANT" + + def __init__(self) -> None: + super().__init__("Игрок или фракция повторяются в партии.") + + +class InvalidRankingError(ValidationError): + code = "INVALID_RANKING" + + def __init__(self, message: str = "Некорректная расстановка мест.") -> None: + super().__init__(message) + + +class InvalidCredentialsError(AuthError): + code = "INVALID_CREDENTIALS" + + def __init__(self) -> None: + super().__init__("Неверный логин или пароль.") + + +async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse: + return exc.to_response() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..42dbf3d --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,120 @@ +"""Безопасность: bcrypt, JWT, cookie сессии и CSRF (double-submit).""" +from __future__ import annotations + +import secrets +from datetime import datetime, timedelta, timezone + +import bcrypt +import jwt +from fastapi import Response + +from app.core.config import settings + +USER_COOKIE = "fs_session" +ADMIN_COOKIE = "fs_admin" +CSRF_COOKIE = "csrf_token" +CSRF_HEADER = "x-csrf-token" + +AUDIENCE_USER = "user" +AUDIENCE_ADMIN = "admin" + +_ADMIN_PATH = "/api/admin" +_USER_PATH = "/" + + +# ─── Пароли ────────────────────────────────────────────────────────────────── + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(password: str, password_hash: str) -> bool: + try: + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + except (ValueError, TypeError): + return False + + +# ─── JWT ───────────────────────────────────────────────────────────────────── + +def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": str(subject), + "aud": audience, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()), + "jti": secrets.token_hex(8), + "provider": provider, + } + return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm) + + +def decode_token(token: str, audience: str) -> dict: + return jwt.decode( + token, + settings.secret_key, + algorithms=[settings.jwt_algorithm], + audience=audience, + ) + + +# ─── Cookie сессии + CSRF ──────────────────────────────────────────────────── + +def generate_csrf_token() -> str: + return secrets.token_urlsafe(24) + + +def _set_csrf_cookie(response: Response, max_age: int) -> str: + csrf = generate_csrf_token() + response.set_cookie( + key=CSRF_COOKIE, + value=csrf, + max_age=max_age, + httponly=False, # должен читаться JS, чтобы продублировать в заголовок + secure=settings.cookie_secure, + samesite="lax", + path="/", + domain=settings.cookie_domain_value, + ) + return csrf + + +def set_user_session(response: Response, user_id: int, provider: str) -> None: + ttl = settings.jwt_user_ttl_minutes + token = create_token(user_id, AUDIENCE_USER, ttl, provider) + response.set_cookie( + key=USER_COOKIE, + value=token, + max_age=ttl * 60, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + path=_USER_PATH, + domain=settings.cookie_domain_value, + ) + _set_csrf_cookie(response, ttl * 60) + + +def set_admin_session(response: Response, admin_id: int) -> None: + ttl = settings.jwt_admin_ttl_minutes + token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local") + response.set_cookie( + key=ADMIN_COOKIE, + value=token, + max_age=ttl * 60, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + path=_ADMIN_PATH, + domain=settings.cookie_domain_value, + ) + _set_csrf_cookie(response, ttl * 60) + + +def clear_user_session(response: Response) -> None: + response.delete_cookie(USER_COOKIE, path=_USER_PATH, domain=settings.cookie_domain_value) + + +def clear_admin_session(response: Response) -> None: + response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value) diff --git a/backend/app/core/timeutil.py b/backend/app/core/timeutil.py new file mode 100644 index 0000000..adf5bf9 --- /dev/null +++ b/backend/app/core/timeutil.py @@ -0,0 +1,29 @@ +"""Работа со временем: хранение в UTC, отдача ISO с явным смещением, +«сегодня» в часовом поясе приложения (по умолчанию +3).""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +from app.core.config import settings + +APP_TZ = timezone(timedelta(hours=settings.app_tz_offset_hours)) + + +def utcnow() -> datetime: + """Текущее время в UTC (aware).""" + return datetime.now(timezone.utc) + + +def app_today() -> date: + """Текущая дата в часовом поясе приложения (для «даты игры»).""" + return datetime.now(APP_TZ).date() + + +def iso_utc(dt: datetime | None) -> str | None: + """ISO-строка с явным UTC-смещением. Наивное значение из SQLite считаем UTC, + чтобы фронт корректно сконвертировал его в локальный пояс отображения.""" + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py new file mode 100644 index 0000000..90a009e --- /dev/null +++ b/backend/app/db/init_db.py @@ -0,0 +1,21 @@ +"""Инициализация схемы и справочников (для тестов и локального быстрого старта). + +В production схема создаётся миграциями Alembic; этот модуль удобен для тестов, +где БД поднимается из чистого состояния. +""" +from __future__ import annotations + +from sqlmodel import SQLModel + +from app.db.session import engine +from app.seed.reference_data import seed_reference_data +from sqlmodel import Session + +# Импорт моделей обязателен, чтобы они зарегистрировались в SQLModel.metadata. +import app.models # noqa: F401 + + +def create_db_and_seed() -> None: + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + seed_reference_data(session) diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..dcccf9b --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,61 @@ +"""Движок БД, PRAGMA для SQLite и зависимость сессии.""" +from __future__ import annotations + +import os +from collections.abc import Iterator + +from sqlalchemy import event +from sqlalchemy.engine import Engine +from sqlmodel import Session, create_engine + +from app.core.config import settings + + +def _ensure_sqlite_dir(url: str) -> None: + """Создаёт каталог для файла SQLite (data/dev в деве, /data на томе в проде).""" + prefix = "sqlite:///" + if not url.startswith(prefix): + return + path = url[len(prefix):] + if path.startswith("/"): # абсолютный путь (sqlite:////...) + file_path = path + else: + file_path = path + directory = os.path.dirname(file_path) + if directory: + os.makedirs(directory, exist_ok=True) + + +_ensure_sqlite_dir(settings.database_url) + +# check_same_thread=False — FastAPI работает в нескольких потоках. +_connect_args = ( + {"check_same_thread": False} + if settings.database_url.startswith("sqlite") + else {} +) + +engine = create_engine( + settings.database_url, + echo=False, + connect_args=_connect_args, +) + + +@event.listens_for(Engine, "connect") +def _set_sqlite_pragma(dbapi_connection, connection_record) -> None: # noqa: ANN001 + """Включает FK и настраивает WAL на каждом соединении SQLite.""" + # Срабатывает для всех движков; PRAGMA применяем только к sqlite3. + if dbapi_connection.__class__.__module__.startswith("sqlite3"): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA busy_timeout=30000") + cursor.close() + + +def get_session() -> Iterator[Session]: + """FastAPI-зависимость: сессия на запрос.""" + with Session(engine) as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..83b224f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,154 @@ +"""Фабрика приложения FastAPI: API под /api + отдача собранного SPA.""" +from __future__ import annotations + +import logging +import os +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from app.core import security +from app.core.config import settings +from app.core.errors import AppError, app_error_handler +from app.routers import admin, auth, groups, matches, reference, stats, users + +# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать). +_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static"))) + +_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} + + +class CSRFMiddleware(BaseHTTPMiddleware): + """Double-submit CSRF: для аутентифицированных мутаций на /api требуем + совпадения заголовка X-CSRF-Token и cookie csrf_token.""" + + async def dispatch(self, request: Request, call_next): # noqa: ANN001 + path = request.url.path + if request.method in _UNSAFE_METHODS and path.startswith("/api"): + has_session = ( + security.USER_COOKIE in request.cookies + or security.ADMIN_COOKIE in request.cookies + ) + if has_session: + cookie_token = request.cookies.get(security.CSRF_COOKIE) + header_token = request.headers.get(security.CSRF_HEADER) + if not cookie_token or cookie_token != header_token: + return JSONResponse( + status_code=403, + content={ + "error": { + "code": "CSRF_FAILED", + "message": "Неверный или отсутствующий CSRF-токен.", + "details": None, + } + }, + ) + return await call_next(request) + + +@asynccontextmanager +async def _lifespan(_app: FastAPI): + # В DEV приложение само подтягивает справочники и админа из .env при старте + # (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0). + if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0": + try: + from app.bootstrap import bootstrap + + bootstrap() + except Exception as exc: # noqa: BLE001 + logging.getLogger("fs").warning( + "Стартовый bootstrap пропущен (примените миграции): %s", exc + ) + yield + + +def create_app() -> FastAPI: + app = FastAPI( + title="Forbidden Stars API", + version="0.1.0", + openapi_url="/api/openapi.json", + docs_url="/api/docs", + redoc_url="/api/redoc", + lifespan=_lifespan, + ) + + if settings.cors_origins_list: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.add_middleware(CSRFMiddleware) + + # Обработчики ошибок → единый конверт. + app.add_exception_handler(AppError, app_error_handler) + + @app.exception_handler(RequestValidationError) + async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse: + return JSONResponse( + status_code=422, + content={ + "error": { + "code": "VALIDATION_ERROR", + "message": "Ошибка валидации запроса.", + "details": exc.errors(), + } + }, + ) + + # API-роутеры под /api. + api_routers = [auth.router, users.router, groups.router, matches.router, + reference.router, stats.router, admin.router] + for r in api_routers: + app.include_router(r, prefix="/api") + + # DEV-вход (по нику) — только в development и только если код физически есть + # (в test/prod-образе dev_auth/dev_stub исключены, импорт просто не выполнится). + if settings.is_development: + try: + from app.routers import dev_auth + + app.include_router(dev_auth.router, prefix="/api") + except ImportError: + pass + + @app.get("/api/health", tags=["meta"]) + def health() -> dict: + return {"status": "ok"} + + _mount_spa(app) + return app + + +def _mount_spa(app: FastAPI) -> None: + """Отдаём собранный SPA: статика + fallback на index.html для client-routes.""" + index_file = _STATIC_DIR / "index.html" + if not index_file.exists(): + return # в dev фронт обслуживает Vite на :5173 + + @app.get("/{full_path:path}", include_in_schema=False) + async def spa(full_path: str): # noqa: ANN202 + # Неизвестный API-путь — это 404 (JSON), а не отдача SPA. + if full_path == "api" or full_path.startswith("api/"): + return JSONResponse( + status_code=404, + content={"error": {"code": "NOT_FOUND", "message": "Не найдено.", "details": None}}, + ) + candidate = (_STATIC_DIR / full_path).resolve() + if ( + full_path + and _STATIC_DIR in candidate.parents + and candidate.is_file() + ): + return FileResponse(candidate) + return FileResponse(index_file) + + +app = create_app() diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..1ae1415 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,288 @@ +"""SQLModel-модели (слой БД). Все таблицы v1 + заготовка под вложения. + +Имена столбцов/таблиц — английские; отображаемые имена справочников (RU) — в `name_ru`. +""" +from __future__ import annotations + +from datetime import date, datetime, timezone + +from sqlalchemy import ( + JSON, + BigInteger, + Boolean, + CheckConstraint, + Column, + Date, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlmodel import Field, SQLModel + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +# ─── Справочники: дополнения и фракции ─────────────────────────────────────── + +class Expansion(SQLModel, table=True): + __tablename__ = "expansions" + + id: int | None = Field(default=None, primary_key=True) + code: str = Field(sa_column=Column(String(32), nullable=False, unique=True)) + name_ru: str = Field(sa_column=Column(String(64), nullable=False)) + is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) + sort_order: int = Field(default=0, nullable=False) + created_at: datetime = Field(default_factory=_utcnow, nullable=False) + + +class Faction(SQLModel, table=True): + __tablename__ = "factions" + + id: int | None = Field(default=None, primary_key=True) + code: str = Field(sa_column=Column(String(32), nullable=False, unique=True)) + name_ru: str = Field(sa_column=Column(String(64), nullable=False)) + expansion_id: int = Field( + sa_column=Column( + Integer, + ForeignKey("expansions.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + ) + sort_order: int = Field(default=0, nullable=False) + created_at: datetime = Field(default_factory=_utcnow, nullable=False) + + +# ─── Пользователи и идентичности ───────────────────────────────────────────── + +class User(SQLModel, table=True): + __tablename__ = "users" + __table_args__ = ( + CheckConstraint("role IN ('player','admin')", name="ck_users_role"), + CheckConstraint( + "auth_provider IN ('stub','telegram','local')", + name="ck_users_auth_provider", + ), + CheckConstraint( + "role <> 'admin' OR password_hash IS NOT NULL", + name="ck_users_admin_has_password", + ), + ) + + id: int | None = Field(default=None, primary_key=True) + telegram_id: int | None = Field( + sa_column=Column(BigInteger, nullable=True, unique=True) + ) + nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True)) + role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player")) + auth_provider: str = Field( + default="stub", + sa_column=Column(String(16), nullable=False, server_default="stub"), + ) + password_hash: str | None = Field(sa_column=Column(String(255), nullable=True)) + active_group_id: int | None = Field( + sa_column=Column( + Integer, + ForeignKey("groups.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + ) + is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1")) + created_at: datetime = Field(default_factory=_utcnow, nullable=False) + updated_at: datetime = Field( + default_factory=_utcnow, + sa_column_kwargs={"onupdate": _utcnow}, + nullable=False, + ) + + +class AuthIdentity(SQLModel, table=True): + __tablename__ = "auth_identity" + __table_args__ = ( + UniqueConstraint("provider", "external_id", name="uq_identity_provider_external"), + ) + + 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 + ) + ) + provider: str = Field(sa_column=Column(String(16), nullable=False)) + external_id: str = Field(sa_column=Column(String(64), nullable=False)) + created_at: datetime = Field(default_factory=_utcnow, nullable=False) + + +# ─── Группы и членство ─────────────────────────────────────────────────────── + +class Group(SQLModel, table=True): + __tablename__ = "groups" + + id: int | None = Field(default=None, primary_key=True) + name: str = Field(sa_column=Column(String(64), nullable=False)) + owner_id: int = Field( + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True + ) + ) + created_at: datetime = Field(default_factory=_utcnow, nullable=False) + updated_at: datetime = Field( + default_factory=_utcnow, + sa_column_kwargs={"onupdate": _utcnow}, + nullable=False, + ) + + +class GroupMember(SQLModel, table=True): + __tablename__ = "group_members" + __table_args__ = ( + UniqueConstraint("group_id", "user_id", name="uq_group_member"), + CheckConstraint("role IN ('owner','member')", name="ck_member_role"), + ) + + 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 + ) + ) + role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member")) + joined_at: datetime = Field(default_factory=_utcnow, nullable=False) + + +class GroupExpansion(SQLModel, table=True): + __tablename__ = "group_expansions" + __table_args__ = ( + UniqueConstraint("group_id", "expansion_id", name="uq_group_expansion"), + ) + + 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 + ) + ) + expansion_id: int = Field( + sa_column=Column( + Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False + ) + ) + created_at: datetime = Field(default_factory=_utcnow, nullable=False) + + +# ─── Партии и участники ────────────────────────────────────────────────────── + +class Match(SQLModel, table=True): + __tablename__ = "matches" + __table_args__ = ( + Index("ix_matches_group_played", "group_id", "played_at"), + CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"), + CheckConstraint( + "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')", + name="ck_match_win_reason", + ), + ) + + id: int | None = Field(default=None, primary_key=True) + group_id: int = Field( + sa_column=Column( + Integer, ForeignKey("groups.id", ondelete="RESTRICT"), nullable=False + ) + ) + # Python-default 'in_progress' (новые партии стартуют незавершёнными). Серверного + # default нет: статус всегда задаётся ORM явно. Бэкфилл старых строк ('finished') + # делает миграция 0003 при ADD COLUMN на существующей БД. + status: str = Field(default="in_progress", sa_column=Column(String(16), nullable=False)) + played_at: date = Field(sa_column=Column(Date, nullable=False)) + started_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True)) + finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True)) + duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) + win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True)) + player_count: int = Field(sa_column=Column(Integer, nullable=False)) + overall_comment: str | None = Field(sa_column=Column(Text, nullable=True)) + created_by: int = Field( + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True + ) + ) + created_at: datetime = Field(default_factory=_utcnow, nullable=False) + updated_at: datetime = Field( + default_factory=_utcnow, + sa_column_kwargs={"onupdate": _utcnow}, + nullable=False, + ) + + +class MatchParticipant(SQLModel, table=True): + __tablename__ = "match_participants" + __table_args__ = ( + # Без UNIQUE(match_id, place) — ничьи разрешены (competition ranking 1,2,2,4). + UniqueConstraint("match_id", "user_id", name="uq_participant_user"), + UniqueConstraint("match_id", "faction_id", name="uq_participant_faction"), + CheckConstraint("place >= 1", name="ck_participant_place"), + ) + + id: int | None = Field(default=None, primary_key=True) + match_id: int = Field( + sa_column=Column( + Integer, ForeignKey("matches.id", ondelete="CASCADE"), nullable=False, index=True + ) + ) + user_id: int = Field( + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True + ) + ) + faction_id: int = Field( + sa_column=Column( + Integer, ForeignKey("factions.id", ondelete="RESTRICT"), nullable=False, index=True + ) + ) + place: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) + was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) + comment: str | None = Field(sa_column=Column(Text, nullable=True)) + created_at: datetime = Field(default_factory=_utcnow, nullable=False) + + +# ─── Журнал аудита ─────────────────────────────────────────────────────────── + +class AuditLog(SQLModel, table=True): + __tablename__ = "audit_log" + __table_args__ = ( + Index("ix_audit_entity", "entity_type", "entity_id"), + Index("ix_audit_created", "created_at"), + ) + + id: int | None = Field(default=None, primary_key=True) + actor_id: int | None = Field( + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + ) + action: str = Field(sa_column=Column(String(32), nullable=False)) + entity_type: str = Field(sa_column=Column(String(32), nullable=False)) + entity_id: int | None = Field(sa_column=Column(Integer, nullable=True)) + payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True)) + ip: str | None = Field(sa_column=Column(String(45), nullable=True)) + user_agent: str | None = Field(sa_column=Column(String(256), nullable=True)) + created_at: datetime = Field(default_factory=_utcnow, nullable=False) + + +# Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно): +# class Attachment(SQLModel, table=True): +# id, match_id (FK CASCADE), participant_id (FK NULL SET NULL), +# uploaded_by (FK), kind ('photo'|'video'), storage_path, mime_type, +# size_bytes, created_at +# Файлы — на томе /data/uploads; в БД только метаданные и относительный путь. diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..ba64fbf --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,311 @@ +"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request, Response +from sqlmodel import Session + +from app.auth.deps import get_current_admin +from app.core import security +from app.core.timeutil import iso_utc +from app.db.session import get_session +from app.models import User +from app.routers.matches import build_match_read +from app.schemas import api as s +from app.services import admin_service, audit_service, faction_service, match_service +from app.services.match_service import ParticipantInput + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +# ─── Аутентификация админа ─────────────────────────────────────────────────── + +@router.post("/auth/login", response_model=s.AdminMe) +def admin_login( + body: s.AdminLogin, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.AdminMe: + admin = admin_service.authenticate_admin(session, body.username, body.password) + security.set_admin_session(response, admin.id) # type: ignore[arg-type] + audit_service.record( + session, + actor_id=admin.id, + action="login", + entity_type="admin", + entity_id=admin.id, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return s.AdminMe(id=admin.id, nickname=admin.nickname, role=admin.role) # type: ignore[arg-type] + + +@router.post("/auth/logout", response_model=s.OkResponse) +def admin_logout(response: Response) -> s.OkResponse: + security.clear_admin_session(response) + return s.OkResponse() + + +@router.get("/me", response_model=s.AdminMe) +def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe: + return s.AdminMe(id=admin.id, nickname=admin.nickname, role=admin.role) # type: ignore[arg-type] + + +# ─── Пользователи ──────────────────────────────────────────────────────────── + +@router.get("/users", response_model=list[s.AdminUserRead]) +def list_users( + query: str | None = Query(None), + session: Session = Depends(get_session), + _admin: User = Depends(get_current_admin), +) -> list[s.AdminUserRead]: + return [ + s.AdminUserRead( + id=u.id, # type: ignore[arg-type] + nickname=u.nickname, + role=u.role, + is_active=u.is_active, + auth_provider=u.auth_provider, + telegram_id=u.telegram_id, + created_at=iso_utc(u.created_at), + ) + for u in admin_service.list_users(session, query) + ] + + +@router.patch("/users/{user_id}", response_model=s.AdminUserRead) +def update_user( + user_id: int, + body: s.AdminUserUpdate, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.AdminUserRead: + u = admin_service.update_user(session, user_id, nickname=body.nickname, is_active=body.is_active) + audit_service.record( + session, + actor_id=admin.id, + action="update", + entity_type="user", + entity_id=user_id, + payload=body.model_dump(exclude_none=True), + ip=request.client.host if request.client else None, + ) + session.commit() + return s.AdminUserRead( + id=u.id, # type: ignore[arg-type] + nickname=u.nickname, + role=u.role, + is_active=u.is_active, + auth_provider=u.auth_provider, + telegram_id=u.telegram_id, + created_at=u.created_at.isoformat(), + ) + + +@router.delete("/users/{user_id}", response_model=s.OkResponse) +def delete_user( + user_id: int, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.OkResponse: + admin_service.delete_user(session, user_id) + audit_service.record( + session, actor_id=admin.id, action="delete", entity_type="user", entity_id=user_id, + ip=request.client.host if request.client else None, + ) + session.commit() + return s.OkResponse() + + +# ─── Группы ────────────────────────────────────────────────────────────────── + +@router.get("/groups", response_model=list[s.AdminGroupRead]) +def list_groups( + session: Session = Depends(get_session), + _admin: User = Depends(get_current_admin), +) -> list[s.AdminGroupRead]: + return [ + s.AdminGroupRead( + id=g.id, name=g.name, owner_id=g.owner_id, created_at=iso_utc(g.created_at) # type: ignore[arg-type] + ) + for g in admin_service.list_groups(session) + ] + + +@router.delete("/groups/{group_id}", response_model=s.OkResponse) +def delete_group( + group_id: int, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.OkResponse: + admin_service.delete_group(session, group_id) + audit_service.record( + session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id, + ip=request.client.host if request.client else None, + ) + session.commit() + return s.OkResponse() + + +# ─── Партии ────────────────────────────────────────────────────────────────── + +@router.get("/matches", response_model=list[s.AdminMatchRead]) +def list_matches( + group_id: int | None = Query(None), + user_id: int | None = Query(None), + faction_id: int | None = Query(None), + limit: int = Query(200, ge=1, le=500), + offset: int = Query(0, ge=0), + session: Session = Depends(get_session), + _admin: User = Depends(get_current_admin), +) -> list[s.AdminMatchRead]: + return [ + s.AdminMatchRead( + id=m.id, # type: ignore[arg-type] + group_id=m.group_id, + group_name=gname, + status=m.status, + played_at=str(m.played_at), + duration_minutes=m.duration_minutes, + win_reason=m.win_reason, # type: ignore[arg-type] + player_count=m.player_count, + created_by=m.created_by, + created_at=iso_utc(m.created_at), + ) + for m, gname in admin_service.list_matches( + session, + limit=limit, + offset=offset, + group_id=group_id, + user_id=user_id, + faction_id=faction_id, + ) + ] + + +@router.get("/matches/{match_id}", response_model=s.MatchRead) +def get_match( + match_id: int, + session: Session = Depends(get_session), + _admin: User = Depends(get_current_admin), +) -> s.MatchRead: + match = match_service.get_match(session, match_id) + return build_match_read(session, match, can_modify=True) + + +@router.patch("/matches/{match_id}", response_model=s.MatchRead) +def update_match( + match_id: int, + body: s.MatchUpdate, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.MatchRead: + match = match_service.get_match(session, match_id) + participants = None + if body.participants is not None: + participants = [ + ParticipantInput( + user_id=p.user_id, + faction_id=p.faction_id, + place=p.place, + was_random=p.was_random, + comment=p.comment, + ) + for p in body.participants + ] + match = match_service.update_match( + session, + match, + played_at=body.played_at, + overall_comment=body.overall_comment, + overall_comment_set=("overall_comment" in body.model_fields_set), + win_reason=body.win_reason, + win_reason_set=("win_reason" in body.model_fields_set), + participants=participants, + ) + audit_service.record( + session, + actor_id=admin.id, + action="update", + entity_type="match", + entity_id=match.id, + ip=request.client.host if request.client else None, + ) + session.commit() + return build_match_read(session, match, can_modify=True) + + +# ─── Фракции (переименование во всей системе) ───────────────────────────────── + +@router.get("/factions", response_model=list[s.FactionRead]) +def list_factions( + session: Session = Depends(get_session), + _admin: User = Depends(get_current_admin), +) -> list[s.FactionRead]: + return [ + s.FactionRead( + id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type] + ) + for f in faction_service.list_factions(session) + ] + + +@router.patch("/factions/{faction_id}", response_model=s.FactionRead) +def rename_faction( + faction_id: int, + body: s.FactionRename, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.FactionRead: + f = admin_service.rename_faction(session, faction_id, body.name_ru) + audit_service.record( + session, + actor_id=admin.id, + action="update", + entity_type="faction", + entity_id=faction_id, + payload={"name_ru": f.name_ru}, + ip=request.client.host if request.client else None, + ) + session.commit() + return s.FactionRead( + id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type] + ) + + +@router.delete("/matches/{match_id}", response_model=s.OkResponse) +def delete_match( + match_id: int, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.OkResponse: + admin_service.delete_match(session, match_id) + audit_service.record( + session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id, + ip=request.client.host if request.client else None, + ) + session.commit() + return s.OkResponse() + + +# ─── Журнал аудита ─────────────────────────────────────────────────────────── + +@router.get("/audit-logs", response_model=s.AuditLogList) +def audit_logs( + action: str | None = Query(None), + entity_type: str | None = Query(None), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), + session: Session = Depends(get_session), + _admin: User = Depends(get_current_admin), +) -> dict: + return admin_service.list_audit_logs( + session, action=action, entity_type=entity_type, limit=limit, offset=offset + ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..c7807dc --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,45 @@ +"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход. + +Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request, Response +from sqlmodel import Session + +from app.auth.login import login_with_identity +from app.auth.registry import enabled_methods +from app.auth.telegram import TelegramProvider +from app.core import security +from app.core.config import settings +from app.db.session import get_session +from app.routers.users import build_me +from app.schemas import api as s + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.get("/config", response_model=s.AuthConfig) +def auth_config() -> s.AuthConfig: + return s.AuthConfig( + methods=enabled_methods(), + telegram_bot_username=settings.telegram_bot_username, + ) + + +@router.post("/telegram", response_model=s.MeRead) +def telegram_login( + body: s.TelegramAuthPayload, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + identity = TelegramProvider().authenticate(body.model_dump()) + user = login_with_identity(session, response, request, identity) + return build_me(session, user) + + +@router.post("/logout", response_model=s.OkResponse) +def logout(response: Response) -> s.OkResponse: + security.clear_user_session(response) + return s.OkResponse() diff --git a/backend/app/routers/dev_auth.py b/backend/app/routers/dev_auth.py new file mode 100644 index 0000000..0a32fcb --- /dev/null +++ b/backend/app/routers/dev_auth.py @@ -0,0 +1,54 @@ +"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи. + +Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore), +а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код +входа по логину остаётся только на деве. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request, Response +from sqlmodel import Session, select + +from app.auth.dev_stub import DevStubProvider +from app.auth.login import login_with_identity +from app.auth.provider import ExternalIdentity +from app.db.session import get_session +from app.models import User +from app.routers.users import build_me +from app.schemas import api as s +from app.services import user_service + +router = APIRouter(prefix="/auth/dev", tags=["auth-dev"]) + + +@router.post("/login", response_model=s.MeRead) +def dev_login( + body: s.DevLogin, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + identity = DevStubProvider().authenticate({"nickname": body.nickname}) + user = login_with_identity(session, response, request, identity) + return build_me(session, user) + + +@router.get("/users", response_model=list[s.DevUserRead]) +def dev_list_users(session: Session = Depends(get_session)) -> list[s.DevUserRead]: + users = session.exec( + select(User).where(User.role == "player").order_by(User.nickname) + ).all() + return [ + s.DevUserRead(id=u.id, nickname=u.nickname, is_active=u.is_active) # type: ignore[arg-type] + for u in users + ] + + +@router.post("/users", response_model=s.DevUserRead) +def dev_create_user( + body: s.DevUserCreate, session: Session = Depends(get_session) +) -> s.DevUserRead: + nickname = body.nickname.strip() + identity = ExternalIdentity(provider="stub", external_id=nickname, suggested_nickname=nickname) + user = user_service.get_or_create_from_identity(session, identity) + return s.DevUserRead(id=user.id, nickname=user.nickname) # type: ignore[arg-type] diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py new file mode 100644 index 0000000..ba4693c --- /dev/null +++ b/backend/app/routers/groups.py @@ -0,0 +1,209 @@ +"""Группы, членство, доступные фракции, список партий и статистика группы.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request +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 ( + audit_service, + group_service, + membership_service, + stats_service, +) + +router = APIRouter(prefix="/groups", tags=["groups"]) + + +def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail: + group = group_service.get_group(session, group_id) + member = group_service.assert_member(session, group_id, user_id) + return s.GroupDetail( + id=group.id, # type: ignore[arg-type] + name=group.name, + owner_id=group.owner_id, + my_role=member.role, + expansion_ids=group_service.group_expansion_ids(session, group_id), + ) + + +@router.get("", response_model=list[s.GroupBrief]) +def my_groups( + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> list[s.GroupBrief]: + return [ + s.GroupBrief(id=g.id, name=g.name, role=role) + for g, role in group_service.list_user_groups(session, user.id) # type: ignore[arg-type] + ] + + +@router.post("", response_model=s.GroupDetail) +def create_group( + body: s.GroupCreate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.GroupDetail: + group = group_service.create_group(session, user, body.name, body.expansion_ids) + audit_service.record( + session, + actor_id=user.id, + action="create", + entity_type="group", + entity_id=group.id, + payload={"name": group.name}, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return _detail(session, group.id, user.id) # type: ignore[arg-type] + + +@router.get("/{group_id}", response_model=s.GroupDetail) +def get_group( + group_id: int, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.GroupDetail: + return _detail(session, group_id, user.id) # type: ignore[arg-type] + + +@router.patch("/{group_id}", response_model=s.GroupDetail) +def rename_group( + group_id: int, + body: s.GroupRename, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.GroupDetail: + group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] + group = group_service.get_group(session, group_id) + group_service.rename_group(session, group, body.name) + return _detail(session, group_id, user.id) # type: ignore[arg-type] + + +@router.put("/{group_id}/expansions", response_model=s.GroupDetail) +def set_expansions( + group_id: int, + body: s.GroupExpansionsUpdate, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.GroupDetail: + group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] + group = group_service.get_group(session, group_id) + group_service.set_expansions(session, group, body.expansion_ids) + return _detail(session, group_id, user.id) # type: ignore[arg-type] + + +@router.get("/{group_id}/factions", response_model=list[s.FactionRead]) +def group_factions( + group_id: int, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> list[s.FactionRead]: + group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] + return [ + s.FactionRead( + id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type] + ) + for f in group_service.available_factions(session, group_id) + ] + + +# ─── Членство ──────────────────────────────────────────────────────────────── + +@router.get("/{group_id}/members", response_model=list[s.MemberRead]) +def list_members( + group_id: int, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> list[s.MemberRead]: + group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] + return [ + s.MemberRead(user_id=u.id, nickname=u.nickname, role=m.role) + for m, u in membership_service.list_members(session, group_id) + ] + + +@router.post("/{group_id}/members", response_model=s.MemberRead) +def add_member( + group_id: int, + body: s.MemberAdd, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MemberRead: + group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] + group = group_service.get_group(session, group_id) + member, added = membership_service.add_member_by_nickname(session, group, body.nickname) + audit_service.record( + session, + actor_id=user.id, + action="create", + entity_type="group_member", + entity_id=group_id, + payload={"added_user_id": added.id, "nickname": added.nickname}, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return s.MemberRead(user_id=added.id, nickname=added.nickname, role=member.role) + + +@router.delete("/{group_id}/members/{user_id}", response_model=s.OkResponse) +def remove_member( + group_id: int, + user_id: int, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.OkResponse: + # Любой участник управляет составом; защита создателя — в membership_service + # (нельзя удалить последнего владельца). + group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] + group = group_service.get_group(session, group_id) + membership_service.remove_member(session, group, user_id) + return s.OkResponse() + + +@router.patch("/{group_id}/members/{user_id}", response_model=s.MemberRead) +def change_member_role( + group_id: int, + user_id: int, + body: s.MemberRoleUpdate, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MemberRead: + group_service.assert_owner(session, group_id, user.id) # type: ignore[arg-type] + group = group_service.get_group(session, group_id) + member = membership_service.change_role(session, group, user_id, body.role) + from app.services.user_service import get_user + + u = get_user(session, user_id) + return s.MemberRead(user_id=u.id, nickname=u.nickname, role=member.role) + + +# ─── Партии и статистика группы ────────────────────────────────────────────── + +@router.get("/{group_id}/matches", response_model=s.MatchList) +def group_matches( + group_id: int, + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> dict: + group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] + return stats_service.group_match_list(session, group_id, limit=limit, offset=offset) + + +@router.get("/{group_id}/stats", response_model=s.GroupStats) +def group_stats( + group_id: int, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> dict: + group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] + return stats_service.group_stats(session, group_id) diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py new file mode 100644 index 0000000..8b38407 --- /dev/null +++ b/backend/app/routers/matches.py @@ -0,0 +1,215 @@ +"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session + +from app.auth.deps import get_current_user +from app.core.errors import NoGroupError +from app.core.timeutil import iso_utc +from app.db.session import get_session +from app.models import Match, User +from app.schemas import api as s +from app.services import audit_service, group_service, match_service +from app.services.match_service import FinishInput, ParticipantInput, RosterInput + +router = APIRouter(prefix="/matches", tags=["matches"]) + + +def _ensure_has_any_group(session: Session, user: User) -> None: + if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type] + raise NoGroupError() + + +def build_match_read(session: Session, match: Match, *, can_modify: bool = False) -> s.MatchRead: + parts = [ + s.MatchParticipantRead( + user_id=u.id, # type: ignore[arg-type] + nickname=u.nickname, + faction_id=f.id, # type: ignore[arg-type] + faction_name=f.name_ru, + place=p.place, + was_random=p.was_random, + comment=p.comment, + ) + for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type] + ] + return s.MatchRead( + id=match.id, # type: ignore[arg-type] + group_id=match.group_id, + status=match.status, + played_at=match.played_at, + started_at=iso_utc(match.started_at), + finished_at=iso_utc(match.finished_at), + duration_minutes=match.duration_minutes, + win_reason=match.win_reason, # type: ignore[arg-type] + player_count=match.player_count, + overall_comment=match.overall_comment, + created_by=match.created_by, + can_modify=can_modify, + participants=parts, + attachments=[], + ) + + +@router.post("/randomize-faction", response_model=s.RandomizeResponse) +def randomize_faction( + body: s.RandomizeRequest, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.RandomizeResponse: + _ensure_has_any_group(session, user) + group_service.assert_member(session, body.group_id, user.id) # type: ignore[arg-type] + faction = match_service.randomize_faction(session, body.group_id, body.exclude_faction_ids) + return s.RandomizeResponse( + faction=s.FactionRead( + id=faction.id, code=faction.code, name_ru=faction.name_ru, expansion_id=faction.expansion_id # type: ignore[arg-type] + ) + ) + + +@router.post("", response_model=s.MatchRead) +def start_match( + body: s.MatchCreate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MatchRead: + """Этап 1: старт партии (выбор игроков и фракций).""" + _ensure_has_any_group(session, user) + roster = [ + RosterInput(user_id=p.user_id, faction_id=p.faction_id, was_random=p.was_random) + for p in body.participants + ] + match = match_service.create_match(session, user, group_id=body.group_id, roster=roster) + audit_service.record( + session, + actor_id=user.id, + action="create", + entity_type="match", + entity_id=match.id, + payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"}, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) + + +@router.post("/{match_id}/finish", response_model=s.MatchRead) +def finish_match( + match_id: int, + body: s.MatchFinish, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MatchRead: + """Этап 2: завершение партии (места, причина победы, длительность).""" + match = match_service.get_match(session, match_id) + match_service.assert_can_modify(session, match, user) + finish = [ + FinishInput(user_id=p.user_id, place=p.place, comment=p.comment, faction_id=p.faction_id) + for p in body.participants + ] + match = match_service.finish_match( + session, + match, + finish=finish, + win_reason=body.win_reason, + overall_comment=body.overall_comment, + overall_comment_set=("overall_comment" in body.model_fields_set), + ) + audit_service.record( + session, + actor_id=user.id, + action="update", + entity_type="match", + entity_id=match.id, + payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes}, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) + + +@router.get("/{match_id}", response_model=s.MatchRead) +def get_match( + match_id: int, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MatchRead: + match = match_service.get_match(session, match_id) + group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type] + return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) + + +@router.patch("/{match_id}", response_model=s.MatchRead) +def update_match( + match_id: int, + body: s.MatchUpdate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MatchRead: + match = match_service.get_match(session, match_id) + match_service.assert_can_modify(session, match, user) + participants = None + if body.participants is not None: + participants = [ + ParticipantInput( + user_id=p.user_id, + faction_id=p.faction_id, + place=p.place, + was_random=p.was_random, + comment=p.comment, + ) + for p in body.participants + ] + match = match_service.update_match( + session, + match, + played_at=body.played_at, + overall_comment=body.overall_comment, + overall_comment_set=("overall_comment" in body.model_fields_set), + win_reason=body.win_reason, + win_reason_set=("win_reason" in body.model_fields_set), + participants=participants, + ) + audit_service.record( + session, + actor_id=user.id, + action="update", + entity_type="match", + entity_id=match.id, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) + + +@router.delete("/{match_id}", response_model=s.OkResponse) +def delete_match( + match_id: int, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.OkResponse: + match = match_service.get_match(session, match_id) + match_service.assert_can_modify(session, match, user) + match_id_val = match.id + group_id_val = match.group_id + match_service.delete_match(session, match) + audit_service.record( + session, + actor_id=user.id, + action="delete", + entity_type="match", + entity_id=match_id_val, + payload={"group_id": group_id_val}, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return s.OkResponse() diff --git a/backend/app/routers/reference.py b/backend/app/routers/reference.py new file mode 100644 index 0000000..e34448c --- /dev/null +++ b/backend/app/routers/reference.py @@ -0,0 +1,37 @@ +"""Справочники: дополнения и фракции (для всех авторизованных).""" +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 faction_service + +router = APIRouter(tags=["reference"]) + + +@router.get("/expansions", response_model=list[s.ExpansionRead]) +def list_expansions( + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[s.ExpansionRead]: + return [ + s.ExpansionRead(id=e.id, code=e.code, name_ru=e.name_ru, is_base=e.is_base) # type: ignore[arg-type] + for e in faction_service.list_expansions(session) + ] + + +@router.get("/factions", response_model=list[s.FactionRead]) +def list_factions( + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[s.FactionRead]: + return [ + s.FactionRead( + id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type] + ) + for f in faction_service.list_factions(session) + ] diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py new file mode 100644 index 0000000..f5a9f37 --- /dev/null +++ b/backend/app/routers/stats.py @@ -0,0 +1,32 @@ +"""Рейтинги и агрегат главной страницы.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +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 stats_service + +router = APIRouter(tags=["stats"]) + + +@router.get("/stats/leaderboard", response_model=s.Leaderboard) +def overall_leaderboard( + limit: int = Query(50, ge=1, le=200), + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + board = stats_service.leaderboard(session, group_id=None) + board["entries"] = board["entries"][:limit] + return board + + +@router.get("/home", response_model=s.HomeResponse) +def home( + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> dict: + return stats_service.home(session, user.id, user.active_group_id) # type: ignore[arg-type] diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..c399f1e --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,94 @@ +"""Роутер текущего пользователя.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request +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 audit_service, group_service, stats_service, user_service + +router = APIRouter(prefix="/users", tags=["users"]) + + +def build_me(session: Session, user: User) -> s.MeRead: + groups = [ + s.GroupBrief(id=g.id, name=g.name, role=role) + for g, role in group_service.list_user_groups(session, user.id) # type: ignore[arg-type] + ] + return s.MeRead( + id=user.id, # type: ignore[arg-type] + nickname=user.nickname, + role=user.role, + auth_provider=user.auth_provider, + telegram_id=user.telegram_id, + active_group_id=user.active_group_id, + groups=groups, + ) + + +@router.get("/me", response_model=s.MeRead) +def get_me( + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MeRead: + return build_me(session, user) + + +@router.patch("/me", response_model=s.UserRead) +def update_me( + body: s.NicknameUpdate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.UserRead: + user_service.update_nickname(session, user, body.nickname) + audit_service.record( + session, + actor_id=user.id, + action="update", + entity_type="user", + entity_id=user.id, + payload={"nickname": user.nickname}, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return s.UserRead( + id=user.id, # type: ignore[arg-type] + nickname=user.nickname, + role=user.role, + auth_provider=user.auth_provider, + telegram_id=user.telegram_id, + active_group_id=user.active_group_id, + ) + + +@router.get("/me/stats", response_model=s.ProfileStats) +def my_stats( + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> dict: + return stats_service.profile_stats(session, user.id) # type: ignore[arg-type] + + +@router.put("/me/active-group", response_model=s.MeRead) +def set_active_group( + body: s.ActiveGroupUpdate, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MeRead: + user_service.set_active_group(session, user, body.group_id) + return build_me(session, user) + + +@router.get("/nickname-available", response_model=s.NicknameAvailable) +def nickname_available( + value: str = Query(..., min_length=1), + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.NicknameAvailable: + available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id) + return s.NicknameAvailable(available=available) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py new file mode 100644 index 0000000..bd90d8c --- /dev/null +++ b/backend/app/schemas/api.py @@ -0,0 +1,406 @@ +"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта.""" +from __future__ import annotations + +from datetime import date +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +WinReason = Literal["objectives", "worlds", "plastic", "resources"] + + +# ─── Auth ──────────────────────────────────────────────────────────────────── + +class AuthConfig(BaseModel): + # Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве. + methods: list[str] = [] + telegram_bot_username: str | None = None + + +class TelegramAuthPayload(BaseModel): + # Полезная нагрузка Telegram Login Widget (проверяется по HMAC). + model_config = ConfigDict(extra="allow") + id: int + auth_date: int + hash: str + first_name: str | None = None + last_name: str | None = None + username: str | None = None + photo_url: str | None = None + + +class DevLogin(BaseModel): + nickname: str + + +class DevUserCreate(BaseModel): + nickname: str + + +class DevUserRead(BaseModel): + id: int + nickname: str + is_active: bool = True + + +class OkResponse(BaseModel): + ok: bool = True + + +# ─── Справочники ───────────────────────────────────────────────────────────── + +class ExpansionRead(BaseModel): + id: int + code: str + name_ru: str + is_base: bool + + +class FactionRead(BaseModel): + id: int + code: str + name_ru: str + expansion_id: int + + +class FactionRename(BaseModel): + name_ru: str + + +# ─── Пользователь ──────────────────────────────────────────────────────────── + +class GroupBrief(BaseModel): + id: int + name: str + role: str + + +class UserRead(BaseModel): + id: int + nickname: str + role: str + auth_provider: str + telegram_id: int | None = None + active_group_id: int | None = None + + +class MeRead(UserRead): + groups: list[GroupBrief] = [] + + +class NicknameUpdate(BaseModel): + nickname: str + + +class ActiveGroupUpdate(BaseModel): + group_id: int | None = None + + +class NicknameAvailable(BaseModel): + available: bool + + +# ─── Группы и членство ─────────────────────────────────────────────────────── + +class GroupCreate(BaseModel): + name: str + expansion_ids: list[int] = [] + + +class GroupRename(BaseModel): + name: str + + +class GroupExpansionsUpdate(BaseModel): + expansion_ids: list[int] = [] + + +class GroupDetail(BaseModel): + id: int + name: str + owner_id: int + my_role: str + expansion_ids: list[int] = [] + + +class MemberRead(BaseModel): + user_id: int + nickname: str + role: str + + +class MemberAdd(BaseModel): + nickname: str + + +class MemberRoleUpdate(BaseModel): + role: str + + +# ─── Партии ────────────────────────────────────────────────────────────────── + +class RandomizeRequest(BaseModel): + group_id: int + exclude_faction_ids: list[int] = [] + + +class RandomizeResponse(BaseModel): + faction: FactionRead + + +# Этап 1 (старт): только ростер, без мест. +class RosterParticipant(BaseModel): + user_id: int + faction_id: int + was_random: bool = False + + +class MatchCreate(BaseModel): + group_id: int + participants: list[RosterParticipant] + + +# Этап 2 (завершение): места, комментарии, причина победы. +class MatchFinishParticipant(BaseModel): + user_id: int + place: int = Field(ge=1) + comment: str | None = None + faction_id: int | None = None # опц. смена фракции при завершении + + +class MatchFinish(BaseModel): + participants: list[MatchFinishParticipant] + win_reason: WinReason + overall_comment: str | None = None + + +# Полный участник (правка завершённой партии админом). +class ParticipantInput(BaseModel): + user_id: int + faction_id: int + place: int = Field(ge=1) + was_random: bool = False + comment: str | None = None + + +class MatchUpdate(BaseModel): + played_at: date | None = None + overall_comment: str | None = None + win_reason: WinReason | None = None + participants: list[ParticipantInput] | None = None + + +class MatchParticipantRead(BaseModel): + user_id: int + nickname: str + faction_id: int + faction_name: str + place: int | None = None + was_random: bool + comment: str | None = None + + +class MatchRead(BaseModel): + id: int + group_id: int + status: str + played_at: date + started_at: str | None = None + finished_at: str | None = None + duration_minutes: int | None = None + win_reason: WinReason | None = None + player_count: int + overall_comment: str | None = None + created_by: int + can_modify: bool = False # может ли текущий зритель править/завершать партию + participants: list[MatchParticipantRead] = [] + attachments: list[Any] = [] # задел под вложения (всегда пусто в v1) + + +# ─── Статистика ────────────────────────────────────────────────────────────── + +class OverallStats(BaseModel): + games: int + wins: int + win_rate: float + avg_place: float | None = None + score: float | None = None + + +class LeaderboardEntry(OverallStats): + user_id: int + nickname: str + rank: int | None = None + + +class Leaderboard(BaseModel): + entries: list[LeaderboardEntry] = [] + provisional: list[LeaderboardEntry] = [] + min_games: int + + +class FactionStat(BaseModel): + faction_id: int + code: str + name_ru: str + expansion_code: str + games: int + wins: int + win_rate: float + avg_place: float | None = None + score: float | None = None + + +class RecentFormItem(BaseModel): + place: int + player_count: int + played_at: str + + +class ProfileStats(BaseModel): + user_id: int + overall: OverallStats + factions: list[FactionStat] = [] + best_faction: FactionStat | None = None + worst_faction: FactionStat | None = None + most_played_faction: FactionStat | None = None + recent_form: list[RecentFormItem] = [] + + +class FactionMeta(BaseModel): + faction_id: int + code: str + name_ru: str + games: int + wins: int + available: bool + + +class GroupStats(BaseModel): + group_id: int + total_matches: int + last_match_at: str | None = None + leaderboard: list[LeaderboardEntry] = [] + provisional: list[LeaderboardEntry] = [] + faction_meta: list[FactionMeta] = [] + min_games: int + + +class MatchListParticipant(BaseModel): + user_id: int + nickname: str + faction_id: int + faction_name: str + place: int | None = None + was_random: bool + comment: str | None = None + + +class MatchListItem(BaseModel): + id: int + status: str + played_at: str + started_at: str | None = None + finished_at: str | None = None + duration_minutes: int | None = None + win_reason: WinReason | None = None + player_count: int + overall_comment: str | None = None + created_by: int + participants: list[MatchListParticipant] = [] + + +class MatchList(BaseModel): + items: list[MatchListItem] = [] + total: int + limit: int + offset: int + + +class GroupBriefStats(OverallStats): + id: int + name: str + + +class HomeInProgressMatch(BaseModel): + id: int + group_id: int + group_name: str + started_at: str | None = None + player_count: int + participants: list[MatchListParticipant] = [] + + +class HomeResponse(BaseModel): + leaderboard: list[LeaderboardEntry] = [] + provisional: list[LeaderboardEntry] = [] + profile: ProfileStats + active_group: GroupBriefStats | None = None + in_progress: list[HomeInProgressMatch] = [] + min_games: int + + +# ─── Админ ─────────────────────────────────────────────────────────────────── + +class AdminLogin(BaseModel): + username: str + password: str + + +class AdminMe(BaseModel): + id: int + nickname: str + role: str + + +class AdminUserRead(BaseModel): + id: int + nickname: str + role: str + is_active: bool + auth_provider: str + telegram_id: int | None = None + created_at: str + + +class AdminUserUpdate(BaseModel): + nickname: str | None = None + is_active: bool | None = None + + +class AdminGroupRead(BaseModel): + id: int + name: str + owner_id: int + created_at: str + + +class AdminMatchRead(BaseModel): + id: int + group_id: int + group_name: str | None = None + status: str + played_at: str + duration_minutes: int | None = None + win_reason: WinReason | None = None + player_count: int + created_by: int + created_at: str + + +class AuditLogItem(BaseModel): + id: int + actor_id: int | None = None + action: str + entity_type: str + entity_id: int | None = None + payload: dict | None = None + ip: str | None = None + user_agent: str | None = None + created_at: str + + +class AuditLogList(BaseModel): + items: list[AuditLogItem] = [] + limit: int + offset: int diff --git a/backend/app/seed/__init__.py b/backend/app/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/seed/reference_data.py b/backend/app/seed/reference_data.py new file mode 100644 index 0000000..48c72a0 --- /dev/null +++ b/backend/app/seed/reference_data.py @@ -0,0 +1,68 @@ +"""Справочные данные: дополнения и фракции. Идемпотентный сидинг по `code`.""" +from __future__ import annotations + +from sqlmodel import Session, select + +from app.models import Expansion, Faction + +# (code, name_ru, is_base, sort_order) +EXPANSIONS: list[tuple[str, str, bool, int]] = [ + ("base", "Базовая игра", True, 0), + ("forgotten_worlds", "Forgotten Worlds", False, 1), + ("forsaken_voids", "Forsaken Voids", False, 2), +] + +# (code, name_ru, expansion_code, sort_order) +FACTIONS: list[tuple[str, str, str, int]] = [ + # База + ("orks", "Орки", "base", 0), + ("ultramarines", "Ультрамарины", "base", 1), + ("eldar", "Эльдары", "base", 2), + ("chaos", "Хаоситы", "base", 3), + # Forgotten Worlds + ("astra_militarum", "Имперская гвардия", "forgotten_worlds", 0), + ("tau", "Тау", "forgotten_worlds", 1), + ("necrons", "Некроны", "forgotten_worlds", 2), + ("tyranids", "Тираниды", "forgotten_worlds", 3), + # Forsaken Voids + ("inquisition", "Инквизиция", "forsaken_voids", 0), + ("sisters_of_battle", "Сёстры битвы", "forsaken_voids", 1), + ("drukhari", "Друкхари", "forsaken_voids", 2), + ("adeptus_mechanicus", "Адептус Механикус", "forsaken_voids", 3), +] + + +def seed_reference_data(session: Session) -> None: + """Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.""" + code_to_expansion: dict[str, Expansion] = {} + + for code, name_ru, is_base, order in EXPANSIONS: + exp = session.exec(select(Expansion).where(Expansion.code == code)).first() + if exp is None: + exp = Expansion(code=code, name_ru=name_ru, is_base=is_base, sort_order=order) + session.add(exp) + else: + exp.name_ru = name_ru + exp.is_base = is_base + exp.sort_order = order + code_to_expansion[code] = exp + + session.flush() # получить id дополнений + + for code, name_ru, exp_code, order in FACTIONS: + expansion = code_to_expansion[exp_code] + fac = session.exec(select(Faction).where(Faction.code == code)).first() + if fac is None: + fac = Faction( + code=code, + name_ru=name_ru, + expansion_id=expansion.id, # type: ignore[arg-type] + sort_order=order, + ) + session.add(fac) + else: + fac.name_ru = name_ru + fac.expansion_id = expansion.id # type: ignore[assignment] + fac.sort_order = order + + session.commit() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py new file mode 100644 index 0000000..2c1c3d2 --- /dev/null +++ b/backend/app/services/admin_service.py @@ -0,0 +1,168 @@ +"""Админ: аутентификация (логин=ник + пароль) и управление сущностями.""" +from __future__ import annotations + +from typing import Any + +from sqlmodel import Session, select + +from app.core.errors import InvalidCredentialsError, NotFoundError, ValidationError +from app.core.security import verify_password +from app.core.timeutil import iso_utc +from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User + + +def authenticate_admin(session: Session, username: str, password: str) -> User: + user = session.exec( + select(User).where( + User.nickname == username, + User.role == "admin", + User.auth_provider == "local", + User.is_active == True, # noqa: E712 + ) + ).first() + if user is None or not user.password_hash or not verify_password(password, user.password_hash): + raise InvalidCredentialsError() + return user + + +# ─── Пользователи ──────────────────────────────────────────────────────────── + +def list_users(session: Session, query: str | None = None) -> list[User]: + stmt = select(User).order_by(User.created_at.desc()) + if query: + stmt = stmt.where(User.nickname.contains(query)) + return list(session.exec(stmt).all()) + + +def update_user(session: Session, user_id: int, *, nickname: str | None = None, is_active: bool | None = None) -> User: + user = session.get(User, user_id) + if user is None: + raise NotFoundError("Пользователь не найден.") + if nickname is not None: + user.nickname = nickname.strip() + if is_active is not None: + user.is_active = is_active + session.add(user) + session.commit() + session.refresh(user) + return user + + +def delete_user(session: Session, user_id: int) -> None: + user = session.get(User, user_id) + if user is None: + raise NotFoundError("Пользователь не найден.") + session.delete(user) + session.commit() + + +# ─── Группы ────────────────────────────────────────────────────────────────── + +def list_groups(session: Session) -> list[Group]: + return list(session.exec(select(Group).order_by(Group.created_at.desc())).all()) + + +def delete_group(session: Session, group_id: int) -> None: + group = session.get(Group, group_id) + if group is None: + raise NotFoundError("Группа не найдена.") + session.delete(group) + session.commit() + + +# ─── Партии ────────────────────────────────────────────────────────────────── + +def list_matches( + session: Session, + limit: int = 200, + offset: int = 0, + group_id: int | None = None, + user_id: int | None = None, + faction_id: int | None = None, +) -> list[tuple[Match, str]]: + """Возвращает партии вместе с названием группы (для группировки в админке). + + Фильтры: по группе, по игроку-участнику, по фракции участника. + """ + stmt = ( + select(Match, Group.name) + .join(Group, Group.id == Match.group_id) + .order_by(Match.group_id, Match.played_at.desc(), Match.id.desc()) + ) + if group_id is not None: + stmt = stmt.where(Match.group_id == group_id) + if user_id is not None: + stmt = stmt.where( + Match.id.in_( + select(MatchParticipant.match_id).where(MatchParticipant.user_id == user_id) + ) + ) + if faction_id is not None: + stmt = stmt.where( + Match.id.in_( + select(MatchParticipant.match_id).where( + MatchParticipant.faction_id == faction_id + ) + ) + ) + rows = session.exec(stmt.offset(offset).limit(limit)).all() + return [(mt, gname) for mt, gname in rows] + + +def rename_faction(session: Session, faction_id: int, name_ru: str) -> Faction: + """Переименовывает фракцию во всей системе (имя хранится один раз).""" + name_ru = (name_ru or "").strip() + if not (1 <= len(name_ru) <= 64): + raise ValidationError("Название фракции: 1–64 символа.") + faction = session.get(Faction, faction_id) + if faction is None: + raise NotFoundError("Фракция не найдена.") + faction.name_ru = name_ru + session.add(faction) + session.commit() + session.refresh(faction) + return faction + + +def delete_match(session: Session, match_id: int) -> None: + match = session.get(Match, match_id) + if match is None: + raise NotFoundError("Партия не найдена.") + session.delete(match) + session.commit() + + +# ─── Журнал аудита ─────────────────────────────────────────────────────────── + +def list_audit_logs( + session: Session, + *, + action: str | None = None, + entity_type: str | None = None, + limit: int = 100, + offset: int = 0, +) -> dict[str, Any]: + stmt = select(AuditLog).order_by(AuditLog.created_at.desc()) + if action: + stmt = stmt.where(AuditLog.action == action) + if entity_type: + stmt = stmt.where(AuditLog.entity_type == entity_type) + rows = session.exec(stmt.offset(offset).limit(limit)).all() + return { + "items": [ + { + "id": r.id, + "actor_id": r.actor_id, + "action": r.action, + "entity_type": r.entity_type, + "entity_id": r.entity_id, + "payload": r.payload, + "ip": r.ip, + "user_agent": r.user_agent, + "created_at": iso_utc(r.created_at), + } + for r in rows + ], + "limit": limit, + "offset": offset, + } diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..acd4237 --- /dev/null +++ b/backend/app/services/audit_service.py @@ -0,0 +1,33 @@ +"""Журнал аудита: запись действий, изменяющих состояние.""" +from __future__ import annotations + +from typing import Any + +from sqlmodel import Session + +from app.models import AuditLog + + +def record( + session: Session, + *, + actor_id: int | None, + action: str, + entity_type: str, + entity_id: int | None = None, + payload: dict[str, Any] | None = None, + ip: str | None = None, + user_agent: str | None = None, +) -> None: + """Добавляет запись аудита в сессию (commit — на стороне вызывающего).""" + session.add( + AuditLog( + actor_id=actor_id, + action=action, + entity_type=entity_type, + entity_id=entity_id, + payload=payload, + ip=ip, + user_agent=user_agent, + ) + ) diff --git a/backend/app/services/faction_service.py b/backend/app/services/faction_service.py new file mode 100644 index 0000000..8d2529d --- /dev/null +++ b/backend/app/services/faction_service.py @@ -0,0 +1,20 @@ +"""Справочники: дополнения и фракции.""" +from __future__ import annotations + +from sqlmodel import Session, select + +from app.models import Expansion, Faction + + +def list_expansions(session: Session) -> list[Expansion]: + return list( + session.exec(select(Expansion).order_by(Expansion.sort_order)).all() + ) + + +def list_factions(session: Session) -> list[Faction]: + return list( + session.exec( + select(Faction).order_by(Faction.expansion_id, Faction.sort_order) + ).all() + ) diff --git a/backend/app/services/group_service.py b/backend/app/services/group_service.py new file mode 100644 index 0000000..7c9f3df --- /dev/null +++ b/backend/app/services/group_service.py @@ -0,0 +1,138 @@ +"""Группы: создание, дополнения, доступные фракции, проверки доступа.""" +from __future__ import annotations + +from sqlalchemy import or_ +from sqlmodel import Session, select + +from app.core.errors import NotFoundError, NotGroupMemberError, ForbiddenError, ValidationError +from app.models import ( + Expansion, + Faction, + Group, + GroupExpansion, + GroupMember, + User, +) + + +def get_group(session: Session, group_id: int) -> Group: + group = session.get(Group, group_id) + if group is None: + raise NotFoundError("Группа не найдена.") + return group + + +def get_membership(session: Session, group_id: int, user_id: int) -> GroupMember | None: + return session.exec( + select(GroupMember).where( + GroupMember.group_id == group_id, GroupMember.user_id == user_id + ) + ).first() + + +def assert_member(session: Session, group_id: int, user_id: int) -> GroupMember: + member = get_membership(session, group_id, user_id) + if member is None: + raise NotGroupMemberError() + return member + + +def assert_owner(session: Session, group_id: int, user_id: int) -> GroupMember: + member = assert_member(session, group_id, user_id) + if member.role != "owner": + raise ForbiddenError("Действие доступно только владельцу группы.") + return member + + +def list_user_groups(session: Session, user_id: int) -> list[tuple[Group, str]]: + rows = session.exec( + select(Group, GroupMember.role) + .join(GroupMember, GroupMember.group_id == Group.id) + .where(GroupMember.user_id == user_id) + .order_by(Group.name) + ).all() + return [(g, role) for g, role in rows] + + +def _valid_non_base_expansion_ids(session: Session, expansion_ids: list[int]) -> list[int]: + if not expansion_ids: + return [] + found = session.exec( + select(Expansion.id).where( + Expansion.id.in_(expansion_ids), Expansion.is_base == False # noqa: E712 + ) + ).all() + return list(found) + + +def create_group(session: Session, owner: User, name: str, expansion_ids: list[int]) -> Group: + name = (name or "").strip() + if not (2 <= len(name) <= 64): + raise ValidationError("Название группы: 2–64 символа.") + + group = Group(name=name, owner_id=owner.id) # type: ignore[arg-type] + session.add(group) + session.flush() + + session.add(GroupMember(group_id=group.id, user_id=owner.id, role="owner")) # type: ignore[arg-type] + + for exp_id in _valid_non_base_expansion_ids(session, expansion_ids): + session.add(GroupExpansion(group_id=group.id, expansion_id=exp_id)) # type: ignore[arg-type] + + owner.active_group_id = group.id + session.add(owner) + session.commit() + session.refresh(group) + return group + + +def rename_group(session: Session, group: Group, name: str) -> Group: + name = (name or "").strip() + if not (2 <= len(name) <= 64): + raise ValidationError("Название группы: 2–64 символа.") + group.name = name + session.add(group) + session.commit() + session.refresh(group) + return group + + +def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group: + valid = set(_valid_non_base_expansion_ids(session, expansion_ids)) + current = session.exec( + select(GroupExpansion).where(GroupExpansion.group_id == group.id) + ).all() + current_ids = {ge.expansion_id for ge in current} + + for ge in current: + if ge.expansion_id not in valid: + session.delete(ge) + for exp_id in valid - current_ids: + session.add(GroupExpansion(group_id=group.id, expansion_id=exp_id)) # type: ignore[arg-type] + + session.commit() + session.refresh(group) + return group + + +def group_expansion_ids(session: Session, group_id: int) -> list[int]: + return list( + session.exec( + select(GroupExpansion.expansion_id).where(GroupExpansion.group_id == group_id) + ).all() + ) + + +def available_factions(session: Session, group_id: int) -> list[Faction]: + owned = select(GroupExpansion.expansion_id).where(GroupExpansion.group_id == group_id) + stmt = ( + select(Faction) + .join(Expansion, Expansion.id == Faction.expansion_id) + .where(or_(Expansion.is_base == True, Expansion.id.in_(owned))) # noqa: E712 + .order_by(Expansion.sort_order, Faction.sort_order) + ) + return list(session.exec(stmt).all()) + + +def available_faction_ids(session: Session, group_id: int) -> set[int]: + return {f.id for f in available_factions(session, group_id)} # type: ignore[misc] diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py new file mode 100644 index 0000000..88369c4 --- /dev/null +++ b/backend/app/services/match_service.py @@ -0,0 +1,322 @@ +"""Партии: рандом фракций, двухэтапный поток (старт → завершение), правка/удаление.""" +from __future__ import annotations + +import random +from dataclasses import dataclass +from datetime import date, datetime, timezone + +from sqlmodel import Session, select + +from app.core.errors import ( + ConflictError, + DuplicateParticipantError, + FactionNotAvailableError, + ForbiddenError, + InvalidRankingError, + NotFoundError, + ValidationError, +) +from app.core.timeutil import app_today +from app.models import Faction, GroupMember, Match, MatchParticipant, User +from app.services import group_service + +MAX_MATCH_PLAYERS = 6 +WIN_REASONS = ("objectives", "worlds", "plastic", "resources") + + +@dataclass +class RosterInput: + """Участник на этапе старта (мест ещё нет).""" + + user_id: int + faction_id: int + was_random: bool = False + + +@dataclass +class FinishInput: + """Результат участника на этапе завершения.""" + + user_id: int + place: int + comment: str | None = None + faction_id: int | None = None # опц. смена фракции при завершении + + +@dataclass +class ParticipantInput: + """Полный участник (для правки завершённой партии админом).""" + + user_id: int + faction_id: int + place: int + was_random: bool = False + comment: str | None = None + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def round_to_30(minutes: float) -> int: + """Округление длительности до получаса, минимум 30 минут.""" + return max(30, int(round(minutes / 30.0)) * 30) + + +def get_match(session: Session, match_id: int) -> Match: + match = session.get(Match, match_id) + if match is None: + raise NotFoundError("Партия не найдена.") + return match + + +def participants_detail( + session: Session, match_id: int +) -> list[tuple[MatchParticipant, User, Faction]]: + rows = session.exec( + select(MatchParticipant, User, Faction) + .join(User, User.id == MatchParticipant.user_id) + .join(Faction, Faction.id == MatchParticipant.faction_id) + .where(MatchParticipant.match_id == match_id) + # place может быть NULL (партия идёт) — NULL уходит в конец сортировки. + .order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname) + ).all() + return [(p, u, f) for p, u, f in rows] + + +def randomize_faction( + session: Session, group_id: int, exclude_faction_ids: list[int] | None = None +) -> Faction: + exclude = set(exclude_faction_ids or []) + pool = [f for f in group_service.available_factions(session, group_id) if f.id not in exclude] + if not pool: + raise ValidationError("Нет доступных фракций для рандома.") + return random.choice(pool) + + +def _validate_ranking(places: list[int]) -> None: + """Проверяет competition ranking (1,2,2,4) с допуском ничьих.""" + if any(p < 1 for p in places): + raise InvalidRankingError("Место должно быть ≥ 1.") + ordered = sorted(places) + expected = 1 + i = 0 + n = len(ordered) + while i < n: + current = ordered[i] + if current != expected: + raise InvalidRankingError( + "Места должны идти по правилу 1,2,2,4 (без пропусков перед группой ничьих)." + ) + tie = 0 + while i < n and ordered[i] == current: + tie += 1 + i += 1 + expected = current + tie + + +def _group_member_ids(session: Session, group_id: int) -> set[int]: + return { + m.user_id + for m in session.exec( + select(GroupMember).where(GroupMember.group_id == group_id) + ).all() + } + + +def _validate_roster_basics( + session: Session, + group_id: int, + user_ids: list[int], + faction_ids: list[int], +) -> None: + if len(user_ids) < 2: + raise ValidationError("В партии должно быть не менее 2 участников.") + if len(user_ids) > MAX_MATCH_PLAYERS: + raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.") + if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids): + raise DuplicateParticipantError() + if not set(user_ids).issubset(_group_member_ids(session, group_id)): + raise ValidationError("Все участники должны состоять в группе.") + if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)): + raise FactionNotAvailableError() + + +# ─── Этап 1: старт партии ───────────────────────────────────────────────────── + +def create_match( + session: Session, + creator: User, + *, + group_id: int, + roster: list[RosterInput], +) -> Match: + group_service.assert_member(session, group_id, creator.id) # type: ignore[arg-type] + _validate_roster_basics( + session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster] + ) + + now = _utcnow() + match = Match( + group_id=group_id, + status="in_progress", + played_at=app_today(), # дата игры — в поясе приложения (+3) + started_at=now, + player_count=len(roster), + created_by=creator.id, # type: ignore[arg-type] + ) + session.add(match) + session.flush() + + for r in roster: + session.add( + MatchParticipant( + match_id=match.id, # type: ignore[arg-type] + user_id=r.user_id, + faction_id=r.faction_id, + place=None, + was_random=r.was_random, + ) + ) + session.commit() + session.refresh(match) + return match + + +# ─── Этап 2: завершение партии ──────────────────────────────────────────────── + +def finish_match( + session: Session, + match: Match, + *, + finish: list[FinishInput], + win_reason: str, + overall_comment: str | None = None, + overall_comment_set: bool = False, +) -> Match: + if match.status != "in_progress": + raise ConflictError("Партия уже завершена.") + if win_reason not in WIN_REASONS: + raise ValidationError("Укажите корректную причину победы.") + + existing = { + p.user_id: p + for p in session.exec( + select(MatchParticipant).where(MatchParticipant.match_id == match.id) + ).all() + } + if {f.user_id for f in finish} != set(existing.keys()): + raise ValidationError("Нужно указать результат по всем участникам партии.") + + # Эффективные фракции (с учётом возможной замены при завершении). + eff_factions = { + f.user_id: (f.faction_id if f.faction_id is not None else existing[f.user_id].faction_id) + for f in finish + } + fids = list(eff_factions.values()) + if len(set(fids)) != len(fids): + raise DuplicateParticipantError() + if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)): + raise FactionNotAvailableError() + + _validate_ranking([f.place for f in finish]) + + for f in finish: + p = existing[f.user_id] + p.place = f.place + p.comment = f.comment or None + if f.faction_id is not None: + p.faction_id = f.faction_id + session.add(p) + + now = _utcnow() + match.finished_at = now + started = match.started_at + if started is not None: + if started.tzinfo is not None: + started = started.replace(tzinfo=None) + # max(0, ...) на случай перекоса часов — не уходим в отрицательную длительность. + elapsed_min = max(0.0, (now.replace(tzinfo=None) - started).total_seconds() / 60.0) + match.duration_minutes = round_to_30(elapsed_min) + match.status = "finished" + match.win_reason = win_reason + if overall_comment_set: + match.overall_comment = overall_comment or None + + session.add(match) + session.commit() + session.refresh(match) + return match + + +# ─── Права / правка / удаление ──────────────────────────────────────────────── + +def can_modify(session: Session, match: Match, user: User) -> bool: + # Управление партиями доступно любому участнику группы (а также админу). + if user.role == "admin": + return True + member = group_service.get_membership(session, match.group_id, user.id) # type: ignore[arg-type] + return member is not None + + +def assert_can_modify(session: Session, match: Match, user: User) -> None: + if not can_modify(session, match, user): + raise ForbiddenError("Недостаточно прав для изменения партии.") + + +def update_match( + session: Session, + match: Match, + *, + played_at: date | None = None, + overall_comment: str | None = None, + overall_comment_set: bool = False, + win_reason: str | None = None, + win_reason_set: bool = False, + participants: list[ParticipantInput] | None = None, +) -> Match: + """Правка завершённой партии (админ): полный список участников с местами.""" + if played_at is not None: + match.played_at = played_at + if overall_comment_set: + match.overall_comment = overall_comment or None + if win_reason_set: + if win_reason is not None and win_reason not in WIN_REASONS: + raise ValidationError("Некорректная причина победы.") + match.win_reason = win_reason + + if participants is not None: + _validate_roster_basics( + session, + match.group_id, + [p.user_id for p in participants], + [p.faction_id for p in participants], + ) + _validate_ranking([p.place for p in participants]) + for old in session.exec( + select(MatchParticipant).where(MatchParticipant.match_id == match.id) + ).all(): + session.delete(old) + session.flush() + for p in participants: + session.add( + MatchParticipant( + match_id=match.id, # type: ignore[arg-type] + user_id=p.user_id, + faction_id=p.faction_id, + place=p.place, + was_random=p.was_random, + comment=p.comment or None, + ) + ) + match.player_count = len(participants) + + session.add(match) + session.commit() + session.refresh(match) + return match + + +def delete_match(session: Session, match: Match) -> None: + session.delete(match) # участники удалятся каскадом (FK ON DELETE CASCADE) + session.commit() diff --git a/backend/app/services/membership_service.py b/backend/app/services/membership_service.py new file mode 100644 index 0000000..d4a83b6 --- /dev/null +++ b/backend/app/services/membership_service.py @@ -0,0 +1,92 @@ +"""Членство в группе: список, добавление по нику, удаление, смена роли.""" +from __future__ import annotations + +from sqlmodel import Session, select + +from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError +from app.models import Group, GroupMember, User + +MAX_GROUP_SIZE = 10 + + +def list_members(session: Session, group_id: int) -> list[tuple[GroupMember, User]]: + rows = session.exec( + select(GroupMember, User) + .join(User, User.id == GroupMember.user_id) + .where(GroupMember.group_id == group_id) + .order_by(GroupMember.role.desc(), User.nickname) + ).all() + return [(m, u) for m, u in rows] + + +def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tuple[GroupMember, 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("Игрок с таким ником не найден.") + + member_count = len( + session.exec(select(GroupMember.id).where(GroupMember.group_id == group.id)).all() + ) + if member_count >= MAX_GROUP_SIZE: + raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).") + + existing = session.exec( + select(GroupMember).where( + GroupMember.group_id == group.id, GroupMember.user_id == user.id + ) + ).first() + if existing is not None: + raise ConflictError("Игрок уже в группе.") + + member = GroupMember(group_id=group.id, user_id=user.id, role="member") # type: ignore[arg-type] + session.add(member) + session.commit() + session.refresh(member) + return member, user + + +def remove_member(session: Session, group: Group, user_id: int) -> None: + member = session.exec( + select(GroupMember).where( + GroupMember.group_id == group.id, GroupMember.user_id == user_id + ) + ).first() + if member is None: + raise NotFoundError("Игрок не состоит в группе.") + if member.role == "owner": + owners = session.exec( + select(GroupMember).where( + GroupMember.group_id == group.id, GroupMember.role == "owner" + ) + ).all() + if len(owners) <= 1: + raise ForbiddenError("Нельзя удалить последнего владельца группы.") + + # Сбросить активную группу у тех, для кого она была активной. + user = session.get(User, user_id) + if user is not None and user.active_group_id == group.id: + user.active_group_id = None + session.add(user) + + session.delete(member) + session.commit() + + +def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember: + if role not in ("owner", "member"): + raise ValidationError("Недопустимая роль.") + member = session.exec( + select(GroupMember).where( + GroupMember.group_id == group.id, GroupMember.user_id == user_id + ) + ).first() + if member is None: + raise NotFoundError("Игрок не состоит в группе.") + member.role = role + session.add(member) + session.commit() + session.refresh(member) + return member diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py new file mode 100644 index 0000000..389b86c --- /dev/null +++ b/backend/app/services/scoring.py @@ -0,0 +1,34 @@ +"""Метрика рейтинга. Вынесена отдельно — легко заменить. + +По умолчанию: League Points — нормированные очки за место с учётом размера стола +и ничьих (competition ranking). За партию из N игроков: + points = (N - place - (tie_size - 1)/2) / (N - 1) +1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну. +Рейтинговый счёт игрока = AVG(points) * 100. +""" +from __future__ import annotations + +# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional). +MIN_GAMES = 3 +# Порог числа игр на фракцию для расчёта лучшей/худшей фракции. +FACTION_MIN_GAMES = 2 + +# SQL-выражение очков за участие (tie-aware). Использует поля m.player_count, +# mp.place и t.tie_size (размер группы игроков с тем же местом в партии). +MATCH_POINTS_SQL = ( + "CASE WHEN m.player_count > 1 " + "THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) " + "/ (m.player_count - 1) " + "ELSE 1.0 END" +) + + +def leaderboard_sort_key(row: dict) -> tuple: + """Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.""" + return ( + -(row["score"] or 0.0), + -(row["win_rate"] or 0.0), + -(row["games"] or 0), + (row["avg_place"] or 0.0), + row["nickname"].lower(), + ) diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py new file mode 100644 index 0000000..e196723 --- /dev/null +++ b/backend/app/services/stats_service.py @@ -0,0 +1,353 @@ +"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).""" +from __future__ import annotations + +from typing import Any + +from sqlalchemy import text +from sqlmodel import Session, select + +from app.core.timeutil import iso_utc +from app.models import Group, GroupMember, Match +from app.services import group_service +from app.services.scoring import ( + FACTION_MIN_GAMES, + MATCH_POINTS_SQL, + MIN_GAMES, + leaderboard_sort_key, +) + +# Базовый блок: одна строка на участие с tie-aware очками. +# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят). +SCORED_CTE = f""" +WITH tie AS ( + SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size + FROM match_participants mp + JOIN matches m ON m.id = mp.match_id + WHERE m.status = 'finished' AND mp.place IS NOT NULL + GROUP BY mp.match_id, mp.place +), +scored AS ( + SELECT mp.user_id AS user_id, + mp.faction_id AS faction_id, + m.id AS match_id, + m.group_id AS group_id, + m.played_at AS played_at, + mp.place AS place, + m.player_count AS player_count, + ({MATCH_POINTS_SQL}) AS points, + CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win + FROM match_participants mp + JOIN matches m ON m.id = mp.match_id + JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place + WHERE m.status = 'finished' +) +""" + + +def _round(value: Any, ndigits: int) -> float | None: + return None if value is None else round(float(value), ndigits) + + +def _normalize(row: dict) -> dict: + return { + "user_id": row["user_id"], + "nickname": row["nickname"], + "games": int(row["games"] or 0), + "wins": int(row["wins"] or 0), + "win_rate": _round(row["win_rate"] or 0.0, 4), + "avg_place": _round(row["avg_place"], 2), + "score": _round(row["score"], 1), + } + + +def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]: + where = "WHERE s.group_id = :gid" if group_id is not None else "" + sql = f""" + {SCORED_CTE} + SELECT u.id AS user_id, u.nickname AS nickname, + COUNT(*) AS games, + SUM(s.is_win) AS wins, + AVG(CAST(s.is_win AS FLOAT)) AS win_rate, + AVG(s.place) AS avg_place, + AVG(s.points) * 100 AS score + FROM scored s + JOIN users u ON u.id = s.user_id + {where} + GROUP BY u.id, u.nickname + """ + params = {"gid": group_id} if group_id is not None else {} + result = session.execute(text(sql), params).mappings().all() + return [_normalize(dict(r)) for r in result] + + +def leaderboard(session: Session, group_id: int | None = None) -> dict: + rows = _leaderboard_rows(session, group_id) + qualified = [r for r in rows if r["games"] >= MIN_GAMES] + provisional = [r for r in rows if r["games"] < MIN_GAMES] + qualified.sort(key=leaderboard_sort_key) + provisional.sort(key=leaderboard_sort_key) + for i, r in enumerate(qualified, start=1): + r["rank"] = i + for r in provisional: + r["rank"] = None + return { + "entries": qualified, + "provisional": provisional, + "min_games": MIN_GAMES, + } + + +def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict: + cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "") + sql = f""" + {SCORED_CTE} + SELECT COUNT(*) AS games, + SUM(s.is_win) AS wins, + AVG(CAST(s.is_win AS FLOAT)) AS win_rate, + AVG(s.place) AS avg_place, + AVG(s.points) * 100 AS score + FROM scored s + {cond} + """ + params: dict[str, Any] = {"uid": user_id} + if group_id is not None: + params["gid"] = group_id + r = session.execute(text(sql), params).mappings().first() or {} + return { + "games": int(r.get("games") or 0), + "wins": int(r.get("wins") or 0), + "win_rate": _round(r.get("win_rate") or 0.0, 4), + "avg_place": _round(r.get("avg_place"), 2), + "score": _round(r.get("score"), 1), + } + + +def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> list[dict]: + cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "") + sql = f""" + {SCORED_CTE} + SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, + e.code AS expansion_code, + COUNT(*) AS games, + SUM(s.is_win) AS wins, + AVG(CAST(s.is_win AS FLOAT)) AS win_rate, + AVG(s.place) AS avg_place, + AVG(s.points) * 100 AS score + FROM scored s + JOIN factions f ON f.id = s.faction_id + JOIN expansions e ON e.id = f.expansion_id + {cond} + GROUP BY f.id, f.code, f.name_ru, e.code + ORDER BY games DESC, score DESC + """ + params: dict[str, Any] = {"uid": user_id} + if group_id is not None: + params["gid"] = group_id + result = session.execute(text(sql), params).mappings().all() + out = [] + for r in result: + out.append( + { + "faction_id": r["faction_id"], + "code": r["code"], + "name_ru": r["name_ru"], + "expansion_code": r["expansion_code"], + "games": int(r["games"] or 0), + "wins": int(r["wins"] or 0), + "win_rate": _round(r["win_rate"] or 0.0, 4), + "avg_place": _round(r["avg_place"], 2), + "score": _round(r["score"], 1), + } + ) + return out + + +def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]: + cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "") + sql = f""" + {SCORED_CTE} + SELECT s.place AS place, s.player_count AS player_count, s.played_at AS played_at + FROM scored s + {cond} + ORDER BY s.played_at DESC, s.match_id DESC + LIMIT :lim + """ + params: dict[str, Any] = {"uid": user_id, "lim": limit} + if group_id is not None: + params["gid"] = group_id + result = session.execute(text(sql), params).mappings().all() + return [ + {"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])} + for r in result + ] + + +def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict: + overall = _overall_for_user(session, user_id, group_id) + factions = _faction_breakdown(session, user_id, group_id) + qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES] + best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None + worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None + most_played = max(factions, key=lambda f: f["games"]) if factions else None + return { + "user_id": user_id, + "overall": overall, + "factions": factions, + "best_faction": best, + "worst_faction": worst, + "most_played_faction": most_played, + "recent_form": _recent_form(session, user_id, group_id), + } + + +def group_stats(session: Session, group_id: int) -> dict: + board = leaderboard(session, group_id=group_id) + total_matches = session.exec( + select(Match).where(Match.group_id == group_id, Match.status == "finished") + ).all() + last_at = None + if total_matches: + last_at = str(max(m.played_at for m in total_matches)) + + available_ids = group_service.available_faction_ids(session, group_id) + faction_meta = [] + sql = f""" + {SCORED_CTE} + SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, + COUNT(s.user_id) AS games, SUM(s.is_win) AS wins + FROM factions f + LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid + GROUP BY f.id, f.code, f.name_ru + ORDER BY games DESC, f.sort_order + """ + rows = session.execute(text(sql), {"gid": group_id}).mappings().all() + for r in rows: + faction_meta.append( + { + "faction_id": r["faction_id"], + "code": r["code"], + "name_ru": r["name_ru"], + "games": int(r["games"] or 0), + "wins": int(r["wins"] or 0), + "available": r["faction_id"] in available_ids, + } + ) + + return { + "group_id": group_id, + "total_matches": len(total_matches), + "last_match_at": last_at, + "leaderboard": board["entries"], + "provisional": board["provisional"], + "faction_meta": faction_meta, + "min_games": MIN_GAMES, + } + + +def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict: + from app.services.match_service import participants_detail # избегаем цикла импорта + + total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all()) + matches = session.exec( + select(Match) + .where(Match.group_id == group_id) + .order_by(Match.played_at.desc(), Match.id.desc()) + .offset(offset) + .limit(limit) + ).all() + + items = [] + for m in matches: + parts = [] + for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type] + parts.append( + { + "user_id": u.id, + "nickname": u.nickname, + "faction_id": f.id, + "faction_name": f.name_ru, + "place": p.place, + "was_random": p.was_random, + "comment": p.comment, + } + ) + items.append( + { + "id": m.id, + "status": m.status, + "played_at": str(m.played_at), + "started_at": iso_utc(m.started_at), + "finished_at": iso_utc(m.finished_at), + "duration_minutes": m.duration_minutes, + "win_reason": m.win_reason, + "player_count": m.player_count, + "overall_comment": m.overall_comment, + "created_by": m.created_by, + "participants": parts, + } + ) + return {"items": items, "total": total, "limit": limit, "offset": offset} + + +def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: + """Незавершённые партии во всех группах, где состоит пользователь (новые сверху).""" + from app.services.match_service import participants_detail # избегаем цикла импорта + + group_ids = list( + session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all() + ) + if not group_ids: + return [] + rows = session.exec( + select(Match, Group.name) + .join(Group, Group.id == Match.group_id) + .where(Match.status == "in_progress", Match.group_id.in_(group_ids)) + .order_by(Match.started_at.desc(), Match.id.desc()) + ).all() + out = [] + for m, gname in rows: + parts = [ + { + "user_id": u.id, + "nickname": u.nickname, + "faction_id": f.id, + "faction_name": f.name_ru, + "place": p.place, + "was_random": p.was_random, + "comment": p.comment, + } + for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type] + ] + out.append( + { + "id": m.id, + "group_id": m.group_id, + "group_name": gname, + "started_at": iso_utc(m.started_at), + "player_count": m.player_count, + "participants": parts, + } + ) + return out + + +def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict: + board = leaderboard(session, group_id=None) + profile = profile_stats(session, user_id, group_id=None) + active_group_brief = None + if active_group_id is not None: + group = session.get(Group, active_group_id) + if group is not None: + active_group_brief = { + "id": group.id, + "name": group.name, + **_overall_for_user(session, user_id, active_group_id), + } + return { + "leaderboard": board["entries"][:leaderboard_limit], + "provisional": board["provisional"][:leaderboard_limit], + "profile": profile, + "active_group": active_group_brief, + "in_progress": user_in_progress_matches(session, user_id), + "min_games": MIN_GAMES, + } diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..9295eb5 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,94 @@ +"""Пользователи: создание из внешней личности, ник, активная группа.""" +from __future__ import annotations + +import re + +from sqlmodel import Session, select + +from app.auth.provider import ExternalIdentity +from app.core.errors import NicknameTakenError, NotFoundError, ValidationError +from app.models import AuthIdentity, GroupMember, User + +_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) + + +def get_user(session: Session, user_id: int) -> User: + user = session.get(User, user_id) + if user is None: + raise NotFoundError("Пользователь не найден.") + return user + + +def nickname_available(session: Session, nickname: str, exclude_user_id: int | None = None) -> bool: + stmt = select(User).where(User.nickname == nickname) + existing = session.exec(stmt).first() + return existing is None or existing.id == exclude_user_id + + +def _unique_nickname(session: Session, base: str) -> str: + base = (base or "Игрок").strip()[:60] or "Игрок" + candidate = base + suffix = 1 + while session.exec(select(User).where(User.nickname == candidate)).first() is not None: + suffix += 1 + candidate = f"{base} {suffix}" + return candidate + + +def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User: + link = session.exec( + select(AuthIdentity).where( + AuthIdentity.provider == identity.provider, + AuthIdentity.external_id == identity.external_id, + ) + ).first() + if link is not None: + return get_user(session, link.user_id) + + user = User( + nickname=_unique_nickname(session, identity.suggested_nickname or identity.external_id), + role="player", + auth_provider=identity.provider, + telegram_id=identity.telegram_id, + ) + session.add(user) + session.flush() + session.add( + AuthIdentity( + user_id=user.id, # type: ignore[arg-type] + provider=identity.provider, + external_id=identity.external_id, + ) + ) + session.commit() + session.refresh(user) + return user + + +def update_nickname(session: Session, user: User, new_nickname: str) -> User: + new_nickname = (new_nickname or "").strip() + if not _NICK_RE.match(new_nickname): + raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") + if not nickname_available(session, new_nickname, exclude_user_id=user.id): + raise NicknameTakenError() + user.nickname = new_nickname + session.add(user) + session.commit() + session.refresh(user) + return user + + +def set_active_group(session: Session, user: User, group_id: int | None) -> User: + if group_id is not None: + member = session.exec( + select(GroupMember).where( + GroupMember.group_id == group_id, GroupMember.user_id == user.id + ) + ).first() + if member is None: + raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.") + user.active_group_id = group_id + session.add(user) + session.commit() + session.refresh(user) + return user diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..4e2cf21 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[entrypoint] Применение миграций…" +alembic upgrade head + +echo "[entrypoint] Бутстрап справочников и администратора…" +python -m app.bootstrap + +echo "[entrypoint] Запуск сервера…" +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..8c8f6d8 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "forbidden-stars-backend" +version = "0.1.0" +description = "Forbidden Stars — учёт партий: ядро + REST API (FastAPI)" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.111", + "uvicorn[standard]>=0.30", + "sqlmodel>=0.0.21", + "alembic>=1.13", + "pydantic-settings>=2.3", + "PyJWT>=2.8", + "bcrypt>=4.1", + "python-multipart>=0.0.9", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.2", + "httpx>=0.27", +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["app"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..4ed82ae --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +# Рантайм-зависимости (используются в Docker-сборке для кеширования слоёв) +fastapi>=0.111 +uvicorn[standard]>=0.30 +sqlmodel>=0.0.21 +alembic>=1.13 +pydantic-settings>=2.3 +PyJWT>=2.8 +bcrypt>=4.1 +python-multipart>=0.0.9 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..af004d8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,126 @@ +"""Фикстуры тестов: изолированная in-memory БД + TestClient + помощники.""" +from __future__ import annotations + +import os + +# Тесты НИКОГДА не работают с БД дева/прода. Форсируем тестовое окружение и +# in-memory БД для ГЛОБАЛЬНОГО движка ещё ДО импорта приложения — даже случайное +# обращение к app.db.session.engine не затронет файлы дева/прода, какой бы +# APP_ENV ни был унаследован из окружения. +os.environ["APP_ENV"] = "development" +os.environ["DEV_DATABASE_URL"] = "sqlite://" +os.environ["FS_STARTUP_BOOTSTRAP"] = "0" # тесты сами поднимают схему/данные + +import pytest # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from sqlalchemy.pool import StaticPool # noqa: E402 +from sqlmodel import Session, SQLModel, create_engine # noqa: E402 + +import app.models # noqa: F401,E402 (регистрация моделей) +from app.core.security import hash_password # noqa: E402 +from app.db.session import get_session # noqa: E402 +from app.main import app # noqa: E402 +from app.models import User # noqa: E402 +from app.seed.reference_data import seed_reference_data # noqa: E402 + + +@pytest.fixture() +def engine(): + eng = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(eng) + with Session(eng) as s: + seed_reference_data(s) + yield eng + # In-memory БД исчезает с закрытием соединения StaticPool; drop_all не нужен + # (и невозможен из-за циклической FK users↔groups в SQLite). + eng.dispose() + + +@pytest.fixture() +def client(engine): + def _get_session(): + with Session(engine) as s: + yield s + + app.dependency_overrides[get_session] = _get_session + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + + +@pytest.fixture() +def make_admin(engine): + def _make(username: str = "admin", password: str = "secret123") -> int: + with Session(engine) as s: + admin = User( + nickname=username, + role="admin", + auth_provider="local", + password_hash=hash_password(password), + ) + s.add(admin) + s.commit() + s.refresh(admin) + return admin.id + + return _make + + +# ─── Помощники ──────────────────────────────────────────────────────────────── + +def csrf_headers(client: TestClient) -> dict: + token = client.cookies.get("csrf_token") + return {"X-CSRF-Token": token} if token else {} + + +def login(client: TestClient, nickname: str) -> dict: + # csrf_headers пуст при первом входе и содержит токен при смене пользователя + # (когда уже есть сессионная cookie и middleware требует заголовок). + r = client.post("/api/auth/dev/login", json={"nickname": nickname}, headers=csrf_headers(client)) + assert r.status_code == 200, r.text + return r.json() + + +def start_match(client: TestClient, group_id: int, roster: list[dict]): + """roster: [{user_id, faction_id, was_random?}] → ответ старта (in_progress).""" + return client.post( + "/api/matches", + json={"group_id": group_id, "participants": roster}, + headers=csrf_headers(client), + ) + + +def finish_match( + client: TestClient, + match_id: int, + results: list[dict], + win_reason: str = "objectives", + overall_comment: str | None = None, +): + """results: [{user_id, place, comment?, faction_id?}].""" + body: dict = {"participants": results, "win_reason": win_reason} + if overall_comment is not None: + body["overall_comment"] = overall_comment + return client.post( + f"/api/matches/{match_id}/finish", json=body, headers=csrf_headers(client) + ) + + +def create_finished_match( + client: TestClient, group_id: int, players: list[dict], win_reason: str = "objectives" +) -> dict: + """players: [{user_id, faction_id, place, comment?}] → завершённая партия (для статистики).""" + roster = [{"user_id": p["user_id"], "faction_id": p["faction_id"]} for p in players] + started = start_match(client, group_id, roster) + assert started.status_code == 200, started.text + results = [ + {"user_id": p["user_id"], "place": p["place"], "comment": p.get("comment")} + for p in players + ] + r = finish_match(client, started.json()["id"], results, win_reason) + assert r.status_code == 200, r.text + return r.json() diff --git a/backend/tests/test_admin_extra.py b/backend/tests/test_admin_extra.py new file mode 100644 index 0000000..e52ce61 --- /dev/null +++ b/backend/tests/test_admin_extra.py @@ -0,0 +1,139 @@ +"""Тесты: лимиты (группа/партия), правка партии админом, переименование фракции, фильтры.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import create_finished_match, csrf_headers, login, start_match + + +def _exp_id(client: TestClient, code: str) -> int: + return next(e["id"] for e in client.get("/api/expansions").json() if e["code"] == code) + + +def _admin_login(client: TestClient, make_admin) -> None: + make_admin("admin", "secret123") + r = client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + + +def test_group_size_limit(client: TestClient): + login(client, "Owner") + gid = client.post( + "/api/groups", json={"name": "Большая", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + # owner = 1; добавляем до 10, 10-й участник сверх лимита → 409. + for i in range(1, 11): + client.post("/api/auth/dev/users", json={"nickname": f"U{i}"}, headers=csrf_headers(client)) + statuses = [] + for i in range(1, 11): + r = client.post( + f"/api/groups/{gid}/members", json={"nickname": f"U{i}"}, headers=csrf_headers(client) + ) + statuses.append(r.status_code) + # 9 успешных (итого 10 с владельцем), 10-й — отказ. + assert statuses[:9] == [200] * 9 + assert statuses[9] == 409 + + +def test_match_player_limit(client: TestClient): + me = login(client, "Owner") + fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": [fw, fv]}, headers=csrf_headers(client) + ).json()["id"] + ids = [me["id"]] + for i in range(1, 7): + client.post("/api/auth/dev/users", json={"nickname": f"P{i}"}, headers=csrf_headers(client)) + 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() + fids = [f["id"] for f in factions] + # 7 игроков на старте → 422 (максимум 6). + roster = [{"user_id": uid, "faction_id": fids[i]} for i, uid in enumerate(ids)] + r = start_match(client, gid, roster) + assert r.status_code == 422 + assert "6" in r.json()["error"]["message"] + + +def test_admin_edit_match_and_filters(client: TestClient, make_admin): + me = login(client, "Аня") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client)) + 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()} + mid = create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": factions["orks"], "place": 1}, + {"user_id": b, "faction_id": factions["eldar"], "place": 2}, + ], + )["id"] + + _admin_login(client, make_admin) + + # Фильтры: по игроку и по фракции находят партию. + assert any(m["id"] == mid for m in client.get(f"/api/admin/matches?user_id={b}").json()) + assert any( + m["id"] == mid for m in client.get(f"/api/admin/matches?faction_id={factions['orks']}").json() + ) + + # Правка: меняем фракцию Бори eldar → chaos. + r = client.patch( + f"/api/admin/matches/{mid}", + json={ + "participants": [ + {"user_id": me["id"], "faction_id": factions["orks"], "place": 1}, + {"user_id": b, "faction_id": factions["chaos"], "place": 2}, + ] + }, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + detail = client.get(f"/api/admin/matches/{mid}").json() + bp = next(p for p in detail["participants"] if p["user_id"] == b) + assert bp["faction_name"] == "Хаоситы" + + +def test_admin_rename_faction_system_wide(client: TestClient, make_admin): + me = login(client, "Кто-то") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + client.post("/api/auth/dev/users", json={"nickname": "Друг"}, headers=csrf_headers(client)) + 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()} + mid = create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": factions["orks"], "place": 1}, + {"user_id": b, "faction_id": factions["eldar"], "place": 2}, + ], + )["id"] + + _admin_login(client, make_admin) + r = client.patch( + f"/api/admin/factions/{factions['orks']}", + json={"name_ru": "Орки WAAAGH"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + + # Изменение отражается в существующей партии (имя хранится один раз). + detail = client.get(f"/api/admin/matches/{mid}").json() + ap = next(p for p in detail["participants"] if p["user_id"] == me["id"]) + assert ap["faction_name"] == "Орки WAAAGH" diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..94f9fca --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,81 @@ +"""Аутентификация: методы по окружению, dev-вход, Telegram (проверка подписи).""" +from __future__ import annotations + +import hashlib +import hmac +import time + +from fastapi.testclient import TestClient + +from tests.conftest import csrf_headers + + +def test_auth_config_dev_has_both_methods(client: TestClient): + cfg = client.get("/api/auth/config").json() + assert "telegram" in cfg["methods"] + assert "stub" in cfg["methods"] # dev → доступен вход по нику + + +def test_enabled_methods_by_env(monkeypatch): + from app.auth.registry import enabled_methods + from app.core.config import settings + + monkeypatch.setattr(settings, "app_env", "development") + assert set(enabled_methods()) == {"telegram", "stub"} + monkeypatch.setattr(settings, "app_env", "test") + assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram + monkeypatch.setattr(settings, "app_env", "production") + assert enabled_methods() == ["telegram"] # prod → только Telegram + + +def test_env_flags_and_db_path(monkeypatch): + """dev → файл дева; test и prod → том /data (общая ветвь is_development).""" + from app.core.config import settings + + monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db") + monkeypatch.setattr(settings, "prod_database_url", "sqlite:////data/prod.db") + + monkeypatch.setattr(settings, "app_env", "development") + assert settings.is_development and settings.database_url == "sqlite:///dev.db" + monkeypatch.setattr(settings, "app_env", "test") + assert settings.is_test and settings.database_url == "sqlite:////data/prod.db" + monkeypatch.setattr(settings, "app_env", "production") + assert settings.is_production and settings.database_url == "sqlite:////data/prod.db" + + +def test_dev_login_works(client: TestClient): + r = client.post("/api/auth/dev/login", json={"nickname": "Тестер"}) + assert r.status_code == 200, r.text + assert r.json()["nickname"] == "Тестер" + + +def _telegram_payload(token: str, **fields) -> dict: + data = {"id": 777, "first_name": "Иван", "username": "ivan_tg", "auth_date": int(time.time())} + data.update(fields) + secret = hashlib.sha256(token.encode()).digest() + check = "\n".join(f"{k}={data[k]}" for k in sorted(data)) + sig = hmac.new(secret, check.encode(), hashlib.sha256).hexdigest() + return {**data, "hash": sig} + + +def test_telegram_login_valid_signature(client: TestClient, monkeypatch): + from app.core.config import settings + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + payload = _telegram_payload("TEST_BOT_TOKEN") + r = client.post("/api/auth/telegram", json=payload) + assert r.status_code == 200, r.text + me = r.json() + assert me["nickname"] # пользователь создан + assert me["telegram_id"] == 777 + + +def test_telegram_login_bad_signature_rejected(client: TestClient, monkeypatch): + from app.core.config import settings + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + payload = _telegram_payload("TEST_BOT_TOKEN") + payload["hash"] = "deadbeef" # подделка + r = client.post("/api/auth/telegram", json=payload) + assert r.status_code == 401 + _ = csrf_headers # (для единообразия импорта) diff --git a/backend/tests/test_core_flow.py b/backend/tests/test_core_flow.py new file mode 100644 index 0000000..6409c6c --- /dev/null +++ b/backend/tests/test_core_flow.py @@ -0,0 +1,363 @@ +"""Сквозной тест ядра: вход → группа → участники → партия → статистика → админ.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import csrf_headers, finish_match, login, start_match + + +def _expansion_id(client: TestClient, code: str) -> int: + r = client.get("/api/expansions") + assert r.status_code == 200, r.text + return next(e["id"] for e in r.json() if e["code"] == code) + + +def test_full_flow(client: TestClient, make_admin): + # 1) Вход игрока A — групп ещё нет. + me = login(client, "Иван") + assert me["groups"] == [] + assert me["active_group_id"] is None + + # 2) Без группы создать партию нельзя (NO_GROUP). + r = client.post( + "/api/matches", + json={"group_id": 1, "played_at": "2026-06-15", "participants": []}, + headers=csrf_headers(client), + ) + assert r.status_code == 409 + assert r.json()["error"]["code"] == "NO_GROUP" + + # 3) Создать группу с дополнением Forgotten Worlds. + fw = _expansion_id(client, "forgotten_worlds") + r = client.post( + "/api/groups", + json={"name": "Вечер 40k", "expansion_ids": [fw]}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + group = r.json() + gid = group["id"] + assert group["my_role"] == "owner" + assert fw in group["expansion_ids"] + + # Активная группа выставилась. + me = client.get("/api/users/me").json() + assert me["active_group_id"] == gid + + # 4) Создать игрока B и добавить в группу по нику. + rb = client.post( + "/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. + factions = client.get(f"/api/groups/{gid}/factions").json() + assert len(factions) == 8 + codes = {f["code"] for f in factions} + assert "orks" in codes and "tau" in codes + assert "inquisition" not in codes # Forsaken Voids не подключён + + # 6) Рандом фракции — из доступного набора. + r = client.post( + "/api/matches/randomize-faction", + json={"group_id": gid, "exclude_faction_ids": []}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + assert r.json()["faction"]["id"] in {f["id"] for f in factions} + + # 7) Двухэтапная партия: старт (A=Орки рандом, B=Тау), затем завершение (места). + orks = next(f["id"] for f in factions if f["code"] == "orks") + tau = next(f["id"] for f in factions if f["code"] == "tau") + a_id = me["id"] + started = start_match( + client, + gid, + [ + {"user_id": a_id, "faction_id": orks, "was_random": True}, + {"user_id": b_id, "faction_id": tau}, + ], + ) + assert started.status_code == 200, started.text + sm = started.json() + assert sm["status"] == "in_progress" + assert all(p["place"] is None for p in sm["participants"]) + + # Незавершённая партия в статистику пока не идёт. + assert client.get("/api/home").json()["profile"]["overall"]["games"] == 0 + + fin = finish_match( + client, + sm["id"], + [{"user_id": a_id, "place": 1}, {"user_id": b_id, "place": 2}], + win_reason="objectives", + overall_comment="Хорошая партия", + ) + assert fin.status_code == 200, fin.text + match = fin.json() + assert match["status"] == "finished" + assert match["win_reason"] == "objectives" + assert match["duration_minutes"] is not None and match["duration_minutes"] >= 30 + assert match["player_count"] == 2 + + # 8) Список партий группы и статистика. + lst = client.get(f"/api/groups/{gid}/matches").json() + assert lst["total"] == 1 + gstats = client.get(f"/api/groups/{gid}/stats").json() + assert gstats["total_matches"] == 1 + # Игр меньше MIN_GAMES → попадают в provisional. + prov_ids = {e["user_id"] for e in gstats["provisional"]} + assert a_id in prov_ids and b_id in prov_ids + + # 9) Главная: профиль и топ. + home = client.get("/api/home").json() + assert home["profile"]["overall"]["games"] == 1 + assert home["profile"]["overall"]["wins"] == 1 + assert home["active_group"]["id"] == gid + + # 10) Дубликат фракции запрещён уже на старте. + r = start_match( + client, + gid, + [ + {"user_id": a_id, "faction_id": orks}, + {"user_id": b_id, "faction_id": orks}, + ], + ) + assert r.status_code == 422 + assert r.json()["error"]["code"] == "DUPLICATE_PARTICIPANT" + + # 11) Админ: вход и доступ; пользовательская сессия в админку не проходит. + make_admin("admin", "secret123") + # Пользователь не может в админку. + assert client.get("/api/admin/users").status_code in (401, 403) + # Вход админа. + ra = client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert ra.status_code == 200, ra.text + users = client.get("/api/admin/users").json() + nicks = {u["nickname"] for u in users} + assert {"Иван", "Олег"}.issubset(nicks) + + +def test_in_progress_excluded_and_win_reason_required(client: TestClient): + me = login(client, "Хост") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + client.post("/api/auth/dev/users", json={"nickname": "Гость2"}, headers=csrf_headers(client)) + 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() + f1, f2 = factions[0]["id"], factions[1]["id"] + + started = start_match(client, gid, [ + {"user_id": me["id"], "faction_id": f1}, + {"user_id": b, "faction_id": f2}, + ]) + assert started.status_code == 200, started.text + mid = started.json()["id"] + + # Незавершённая видна в списке группы (можно дозавершить), но не в топе. + lst = client.get(f"/api/groups/{gid}/matches").json() + assert lst["items"][0]["status"] == "in_progress" + assert client.get("/api/stats/leaderboard").json()["entries"] == [] + assert client.get("/api/stats/leaderboard").json()["provisional"] == [] + + # Незавершённая видна на главной (с именем группы) для быстрого перехода. + home = client.get("/api/home").json() + assert any(x["id"] == mid for x in home["in_progress"]) + assert home["in_progress"][0]["group_name"] == "Группа" + + # Завершение без причины победы → 422 (поле обязательно). + no_reason = client.post( + f"/api/matches/{mid}/finish", + json={"participants": [ + {"user_id": me["id"], "place": 1}, + {"user_id": b, "place": 2}, + ]}, + headers=csrf_headers(client), + ) + assert no_reason.status_code == 422 + + # С причиной победы → 200 и теперь учитывается в статистике. + ok = finish_match(client, mid, [ + {"user_id": me["id"], "place": 1}, + {"user_id": b, "place": 2}, + ], win_reason="worlds") + assert ok.status_code == 200, ok.text + assert client.get("/api/users/me/stats").json()["overall"]["games"] == 1 + # После завершения партия пропадает из «незавершённых» на главной. + 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): + """Владелец группы может завершить партию, начатую другим участником (can_modify с бэкенда).""" + a = login(client, "Хозяин") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + client.post("/api/auth/dev/users", json={"nickname": "Согрупник"}, headers=csrf_headers(client)) + 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() + f1, f2 = factions[0]["id"], factions[1]["id"] + + # Участник (не владелец) входит и стартует партию. + login(client, "Согрупник") + started = start_match(client, gid, [ + {"user_id": a["id"], "faction_id": f1}, + {"user_id": b, "faction_id": f2}, + ]) + assert started.status_code == 200, started.text + mid = started.json()["id"] + # Создатель видит can_modify=True. + assert client.get(f"/api/matches/{mid}").json()["can_modify"] is True + + # Владелец возвращается: тоже может завершить (форма должна показываться). + login(client, "Хозяин") + assert client.get(f"/api/matches/{mid}").json()["can_modify"] is True + fin = finish_match(client, mid, [ + {"user_id": a["id"], "place": 1}, + {"user_id": b, "place": 2}, + ], win_reason="objectives") + assert fin.status_code == 200, fin.text + + +def test_any_member_can_finish_and_manage(client: TestClient): + """Рядовой участник (не создатель, не владелец) может завершать партии и менять дополнения.""" + a = login(client, "Овнер") + gid = client.post( + "/api/groups", json={"name": "Компания", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + for nick in ("Бэ", "Цэ"): + client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client)) + 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()] + + # Владелец стартует партию A+B+C. + started = start_match(client, gid, [ + {"user_id": a["id"], "faction_id": fids[0]}, + {"user_id": b, "faction_id": fids[1]}, + {"user_id": c, "faction_id": fids[2]}, + ]) + mid = started.json()["id"] + + # Рядовой участник C завершает партию. + login(client, "Цэ") + assert client.get(f"/api/matches/{mid}").json()["can_modify"] is True + fin = finish_match(client, mid, [ + {"user_id": a["id"], "place": 1}, + {"user_id": b, "place": 2}, + {"user_id": c, "place": 3}, + ], win_reason="plastic") + assert fin.status_code == 200, fin.text + + # И может менять дополнения группы. + fw = _expansion_id(client, "forgotten_worlds") + r = client.put( + f"/api/groups/{gid}/expansions", json={"expansion_ids": [fw]}, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + + +def test_disabled_account_cannot_login(client: TestClient, make_admin): + # Создаём игрока и узнаём его id. + client.post("/api/auth/dev/users", json={"nickname": "Гость"}) + # Вход админа. + make_admin("admin", "secret123") + ra = client.post( + "/api/admin/auth/login", json={"username": "admin", "password": "secret123"} + ) + assert ra.status_code == 200, ra.text + users = client.get("/api/admin/users").json() + guest = next(u for u in users if u["nickname"] == "Гость") + + # Админ отключает аккаунт. + rp = client.patch( + f"/api/admin/users/{guest['id']}", + json={"is_active": False}, + headers=csrf_headers(client), + ) + assert rp.status_code == 200, rp.text + + # Вход под отключённым — запрещён с понятным кодом. + rl = client.post("/api/auth/dev/login", json={"nickname": "Гость"}) + assert rl.status_code == 403 + assert rl.json()["error"]["code"] == "ACCOUNT_DISABLED" + + # В dev-списке он помечен неактивным (для подсветки серым). + dev_users = client.get("/api/auth/dev/users").json() + guest_dev = next(u for u in dev_users if u["nickname"] == "Гость") + assert guest_dev["is_active"] is False + + +def test_tie_ranking_and_points(client: TestClient): + """Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется.""" + me = login(client, "A") + r = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []}, + headers=csrf_headers(client)) + gid = r.json()["id"] + client.post("/api/auth/dev/users", json={"nickname": "B"}, headers=csrf_headers(client)) + client.post("/api/auth/dev/users", json={"nickname": "C"}, headers=csrf_headers(client)) + 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() + f1, f2, f3 = (factions[0]["id"], factions[1]["id"], factions[2]["id"]) + a = me["id"] + + # Старт партии (мест ещё нет) — ранжирование проверяется на завершении. + started = start_match( + client, + gid, + [ + {"user_id": a, "faction_id": f1}, + {"user_id": b, "faction_id": f2}, + {"user_id": c, "faction_id": f3}, + ], + ) + assert started.status_code == 200, started.text + mid = started.json()["id"] + + # Некорректная расстановка 1,1,2 → отклоняется на финише. + bad = finish_match( + client, + mid, + [ + {"user_id": a, "place": 1}, + {"user_id": b, "place": 1}, + {"user_id": c, "place": 2}, + ], + ) + assert bad.status_code == 422 + assert bad.json()["error"]["code"] == "INVALID_RANKING" + + # Корректная ничья за 2-е место: 1,2,2 → партия завершается. + ok = finish_match( + client, + mid, + [ + {"user_id": a, "place": 1}, + {"user_id": b, "place": 2}, + {"user_id": c, "place": 2}, + ], + ) + assert ok.status_code == 200, ok.text + # Повторное завершение запрещено. + again = finish_match(client, mid, [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}, {"user_id": c, "place": 2}]) + assert again.status_code == 409 diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..eb8499c --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,41 @@ +# Локальный «клон прода» в контейнере — для проверки прод-сборки на своей машине +# (а не на Raspberry Pi). Тот же образ, что и прод, но изолированные тома, единый .env +# и порт 8080 (чтобы не конфликтовать с dev-uvicorn на :8000). +# +# Обычно запускается лаунчером при APP_ENV=test (run.ps1 / run.sh). Вручную: +# docker compose -f docker-compose.test.yml up --build -d +# Открыть: http://localhost:8080 (Swagger: /api/docs) +# Остановить и стереть данные: docker compose -f docker-compose.test.yml down -v +services: + app: + build: . + image: forbidden-stars:test + container_name: forbidden-stars-test + restart: "no" + init: true + env_file: + - .env # единый .env (тот же, что у dev/prod); секреты не в git + environment: + # Окружение test: прод-клон, но отличимый от прода (см. config.is_test). + # Форсим здесь, чтобы значение не зависело от APP_ENV внутри .env. + APP_ENV: test + ports: + - "8080:8000" + volumes: + - db-data-test:/data # изолированные тестовые данные + - uploads-data-test:/data/uploads + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + mem_limit: 512m + +volumes: + db-data-test: + uploads-data-test: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8e3409b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + app: + build: . + image: forbidden-stars:latest + restart: unless-stopped + init: true + env_file: + - .env # секреты/настройки (один общий формат для dev и prod) + environment: + # Прод обособлен: контейнер ВСЕГДА production и ИГНОРИРУЕТ APP_ENV из .env + # (хоть development, хоть test). БД=/data, STATIC_DIR=/app/static — из Dockerfile. + APP_ENV: production + ports: + - "8000:8000" + volumes: + - db-data:/data # БД SQLite + WAL-сайдкары + - uploads-data:/data/uploads # задел под фото/видео + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + mem_limit: 512m + cpus: 1.5 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + +volumes: + db-data: + uploads-data: diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..781acf4 --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,3 @@ +# Пути в OpenAPI уже содержат префикс /api, поэтому baseUrl пустой. +# В dev Vite проксирует /api на бэкенд :8000; в prod — тот же origin. +VITE_API_BASE_URL= diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..874a03a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + Forbidden Stars — учёт партий + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8c3aefa --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2205 @@ +{ + "name": "forbidden-stars-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "forbidden-stars-frontend", + "version": "0.1.0", + "dependencies": { + "@hookform/resolvers": "^3.9.0", + "@tanstack/react-query": "^5.51.0", + "date-fns": "^3.6.0", + "lucide-react": "^0.408.0", + "openapi-fetch": "^0.10.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.52.1", + "react-router-dom": "^6.25.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "openapi-typescript": "^7.3.0", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.15", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", + "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.408.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.408.0.tgz", + "integrity": "sha512-8kETAAeWmOvtGIr7HPHm51DXoxlfkNncQ5FZWXR+abX8saQwMYXANWIkUstaYtcKSo/imOe/q+tVFA8ANzdSVA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/openapi-fetch": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.10.6.tgz", + "integrity": "sha512-6xXfvIEL/POtLGOaFPsp3O+pDe+J3DZYxbD9BrsQHXOTeNK8z/gsWHT6adUy1KcpQOhmkerMzlQrJM6DbN55dQ==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.11" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.11.tgz", + "integrity": "sha512-xofUHlVFq+BMquf3nh9I8N2guHckW6mrDO/F3kaFgrL7MGbjldDnQ9TIT+rkH/+H0LiuO+RuZLnNmsJwsjwUKg==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hook-form": { + "version": "7.79.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz", + "integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f4700a8 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "forbidden-stars-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "gen:api": "openapi-typescript http://localhost:8000/api/openapi.json -o src/api/schema.d.ts" + }, + "dependencies": { + "@tanstack/react-query": "^5.51.0", + "@hookform/resolvers": "^3.9.0", + "date-fns": "^3.6.0", + "lucide-react": "^0.408.0", + "openapi-fetch": "^0.10.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.52.1", + "react-router-dom": "^6.25.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "openapi-typescript": "^7.3.0", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ebb3606 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,16 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "react-router-dom"; + +import { queryClient } from "./app/queryClient"; +import { router } from "./app/router"; +import { ToastProvider } from "./context/ToastContext"; + +export function App() { + return ( + + + + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..9baa563 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,55 @@ +import createClient from "openapi-fetch"; +import type { paths } from "./schema"; + +function readCookie(name: string): string | null { + const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")); + return m ? decodeURIComponent(m[1]) : null; +} + +// Пути в схеме уже содержат /api; baseUrl — origin (пусто в dev/prod). +export const api = createClient({ + baseUrl: import.meta.env.VITE_API_BASE_URL || "", + credentials: "include", +}); + +// CSRF double-submit: дублируем cookie csrf_token в заголовок на всех запросах. +api.use({ + onRequest({ request }) { + const csrf = readCookie("csrf_token"); + if (csrf) request.headers.set("X-CSRF-Token", csrf); + return request; + }, +}); + +export class ApiError extends Error { + code: string; + status: number; + details?: unknown; + constructor(message: string, code: string, status: number, details?: unknown) { + super(message); + this.code = code; + this.status = status; + this.details = details; + } +} + +interface FetchResult { + data?: T; + error?: unknown; + response: Response; +} + +/** Разворачивает ответ openapi-fetch: возвращает данные или бросает ApiError. */ +export function unwrap(res: FetchResult): T { + if (res.error || !res.response.ok) { + const env = (res.error as { error?: { code?: string; message?: string; details?: unknown } }) + ?.error; + throw new ApiError( + env?.message || "Ошибка запроса", + env?.code || "ERROR", + res.response.status, + env?.details, + ); + } + return res.data as T; +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts new file mode 100644 index 0000000..c950f57 --- /dev/null +++ b/frontend/src/api/queryKeys.ts @@ -0,0 +1,22 @@ +export const qk = { + me: ["me"] as const, + adminMe: ["adminMe"] as const, + home: ["home"] as const, + leaderboard: ["leaderboard"] as const, + expansions: ["expansions"] as const, + factions: ["factions"] as const, + myStats: ["myStats"] as const, + groups: ["groups"] as const, + group: (id: number) => ["group", id] as const, + groupFactions: (id: number) => ["group", id, "factions"] as const, + groupMembers: (id: number) => ["group", id, "members"] as const, + groupMatches: (id: number) => ["group", id, "matches"] as const, + groupStats: (id: number) => ["group", id, "stats"] as const, + match: (id: number) => ["match", id] as const, + authConfig: ["authConfig"] as const, + devUsers: ["devUsers"] as const, + adminUsers: ["adminUsers"] as const, + adminGroups: ["adminGroups"] as const, + adminMatches: ["adminMatches"] as const, + adminLogs: ["adminLogs"] as const, +}; diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts new file mode 100644 index 0000000..d26fd50 --- /dev/null +++ b/frontend/src/api/schema.d.ts @@ -0,0 +1,2842 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/auth/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Auth Config */ + get: operations["auth_config_api_auth_config_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/telegram": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Telegram Login */ + post: operations["telegram_login_api_auth_telegram_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Logout */ + post: operations["logout_api_auth_logout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Me */ + get: operations["get_me_api_users_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update Me */ + patch: operations["update_me_api_users_me_patch"]; + trace?: never; + }; + "/api/users/me/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** My Stats */ + get: operations["my_stats_api_users_me_stats_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/me/active-group": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Set Active Group */ + put: operations["set_active_group_api_users_me_active_group_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/nickname-available": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Nickname Available */ + get: operations["nickname_available_api_users_nickname_available_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** My Groups */ + get: operations["my_groups_api_groups_get"]; + put?: never; + /** Create Group */ + post: operations["create_group_api_groups_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/groups/{group_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Group */ + get: operations["get_group_api_groups__group_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Rename Group */ + patch: operations["rename_group_api_groups__group_id__patch"]; + trace?: never; + }; + "/api/groups/{group_id}/expansions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Set Expansions */ + put: operations["set_expansions_api_groups__group_id__expansions_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/groups/{group_id}/factions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Group Factions */ + get: operations["group_factions_api_groups__group_id__factions_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/groups/{group_id}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Members */ + get: operations["list_members_api_groups__group_id__members_get"]; + put?: never; + /** Add Member */ + post: operations["add_member_api_groups__group_id__members_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/groups/{group_id}/members/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove Member */ + delete: operations["remove_member_api_groups__group_id__members__user_id__delete"]; + options?: never; + head?: never; + /** Change Member Role */ + patch: operations["change_member_role_api_groups__group_id__members__user_id__patch"]; + trace?: never; + }; + "/api/groups/{group_id}/matches": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Group Matches */ + get: operations["group_matches_api_groups__group_id__matches_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/groups/{group_id}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Group Stats */ + get: operations["group_stats_api_groups__group_id__stats_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/matches/randomize-faction": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Randomize Faction */ + post: operations["randomize_faction_api_matches_randomize_faction_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/matches": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Start Match + * @description Этап 1: старт партии (выбор игроков и фракций). + */ + post: operations["start_match_api_matches_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/matches/{match_id}/finish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Finish Match + * @description Этап 2: завершение партии (места, причина победы, длительность). + */ + post: operations["finish_match_api_matches__match_id__finish_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/matches/{match_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Match */ + get: operations["get_match_api_matches__match_id__get"]; + put?: never; + post?: never; + /** Delete Match */ + delete: operations["delete_match_api_matches__match_id__delete"]; + options?: never; + head?: never; + /** Update Match */ + patch: operations["update_match_api_matches__match_id__patch"]; + trace?: never; + }; + "/api/expansions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Expansions */ + get: operations["list_expansions_api_expansions_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/factions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Factions */ + get: operations["list_factions_api_factions_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/stats/leaderboard": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Overall Leaderboard */ + get: operations["overall_leaderboard_api_stats_leaderboard_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/home": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Home */ + get: operations["home_api_home_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Admin Login */ + post: operations["admin_login_api_admin_auth_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Admin Logout */ + post: operations["admin_logout_api_admin_auth_logout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Admin Me */ + get: operations["admin_me_api_admin_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Users */ + get: operations["list_users_api_admin_users_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/users/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete User */ + delete: operations["delete_user_api_admin_users__user_id__delete"]; + options?: never; + head?: never; + /** Update User */ + patch: operations["update_user_api_admin_users__user_id__patch"]; + trace?: never; + }; + "/api/admin/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Groups */ + get: operations["list_groups_api_admin_groups_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/groups/{group_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete Group */ + delete: operations["delete_group_api_admin_groups__group_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/matches": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Matches */ + get: operations["list_matches_api_admin_matches_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/matches/{match_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Match */ + get: operations["get_match_api_admin_matches__match_id__get"]; + put?: never; + post?: never; + /** Delete Match */ + delete: operations["delete_match_api_admin_matches__match_id__delete"]; + options?: never; + head?: never; + /** Update Match */ + patch: operations["update_match_api_admin_matches__match_id__patch"]; + trace?: never; + }; + "/api/admin/factions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Factions */ + get: operations["list_factions_api_admin_factions_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/factions/{faction_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Rename Faction */ + patch: operations["rename_faction_api_admin_factions__faction_id__patch"]; + trace?: never; + }; + "/api/admin/audit-logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Audit Logs */ + get: operations["audit_logs_api_admin_audit_logs_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/dev/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dev Login */ + post: operations["dev_login_api_auth_dev_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/dev/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Dev List Users */ + get: operations["dev_list_users_api_auth_dev_users_get"]; + put?: never; + /** Dev Create User */ + post: operations["dev_create_user_api_auth_dev_users_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health_api_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** ActiveGroupUpdate */ + ActiveGroupUpdate: { + /** Group Id */ + group_id?: number | null; + }; + /** AdminGroupRead */ + AdminGroupRead: { + /** Id */ + id: number; + /** Name */ + name: string; + /** Owner Id */ + owner_id: number; + /** Created At */ + created_at: string; + }; + /** AdminLogin */ + AdminLogin: { + /** Username */ + username: string; + /** Password */ + password: string; + }; + /** AdminMatchRead */ + AdminMatchRead: { + /** Id */ + id: number; + /** Group Id */ + group_id: number; + /** Group Name */ + group_name?: string | null; + /** Status */ + status: string; + /** Played At */ + played_at: string; + /** Duration Minutes */ + duration_minutes?: number | null; + /** Win Reason */ + win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + /** Player Count */ + player_count: number; + /** Created By */ + created_by: number; + /** Created At */ + created_at: string; + }; + /** AdminMe */ + AdminMe: { + /** Id */ + id: number; + /** Nickname */ + nickname: string; + /** Role */ + role: string; + }; + /** AdminUserRead */ + AdminUserRead: { + /** Id */ + id: number; + /** Nickname */ + nickname: string; + /** Role */ + role: string; + /** Is Active */ + is_active: boolean; + /** Auth Provider */ + auth_provider: string; + /** Telegram Id */ + telegram_id?: number | null; + /** Created At */ + created_at: string; + }; + /** AdminUserUpdate */ + AdminUserUpdate: { + /** Nickname */ + nickname?: string | null; + /** Is Active */ + is_active?: boolean | null; + }; + /** AuditLogItem */ + AuditLogItem: { + /** Id */ + id: number; + /** Actor Id */ + actor_id?: number | null; + /** Action */ + action: string; + /** Entity Type */ + entity_type: string; + /** Entity Id */ + entity_id?: number | null; + /** Payload */ + payload?: { + [key: string]: unknown; + } | null; + /** Ip */ + ip?: string | null; + /** User Agent */ + user_agent?: string | null; + /** Created At */ + created_at: string; + }; + /** AuditLogList */ + AuditLogList: { + /** + * Items + * @default [] + */ + items: components["schemas"]["AuditLogItem"][]; + /** Limit */ + limit: number; + /** Offset */ + offset: number; + }; + /** AuthConfig */ + AuthConfig: { + /** + * Methods + * @default [] + */ + methods: string[]; + /** Telegram Bot Username */ + telegram_bot_username?: string | null; + }; + /** DevLogin */ + DevLogin: { + /** Nickname */ + nickname: string; + }; + /** DevUserCreate */ + DevUserCreate: { + /** Nickname */ + nickname: string; + }; + /** DevUserRead */ + DevUserRead: { + /** Id */ + id: number; + /** Nickname */ + nickname: string; + /** + * Is Active + * @default true + */ + is_active: boolean; + }; + /** ExpansionRead */ + ExpansionRead: { + /** Id */ + id: number; + /** Code */ + code: string; + /** Name Ru */ + name_ru: string; + /** Is Base */ + is_base: boolean; + }; + /** FactionMeta */ + FactionMeta: { + /** Faction Id */ + faction_id: number; + /** Code */ + code: string; + /** Name Ru */ + name_ru: string; + /** Games */ + games: number; + /** Wins */ + wins: number; + /** Available */ + available: boolean; + }; + /** FactionRead */ + FactionRead: { + /** Id */ + id: number; + /** Code */ + code: string; + /** Name Ru */ + name_ru: string; + /** Expansion Id */ + expansion_id: number; + }; + /** FactionRename */ + FactionRename: { + /** Name Ru */ + name_ru: string; + }; + /** FactionStat */ + FactionStat: { + /** Faction Id */ + faction_id: number; + /** Code */ + code: string; + /** Name Ru */ + name_ru: string; + /** Expansion Code */ + expansion_code: string; + /** Games */ + games: number; + /** Wins */ + wins: number; + /** Win Rate */ + win_rate: number; + /** Avg Place */ + avg_place?: number | null; + /** Score */ + score?: number | null; + }; + /** GroupBrief */ + GroupBrief: { + /** Id */ + id: number; + /** Name */ + name: string; + /** Role */ + role: string; + }; + /** GroupBriefStats */ + GroupBriefStats: { + /** Games */ + games: number; + /** Wins */ + wins: number; + /** Win Rate */ + win_rate: number; + /** Avg Place */ + avg_place?: number | null; + /** Score */ + score?: number | null; + /** Id */ + id: number; + /** Name */ + name: string; + }; + /** GroupCreate */ + GroupCreate: { + /** Name */ + name: string; + /** + * Expansion Ids + * @default [] + */ + expansion_ids: number[]; + }; + /** GroupDetail */ + GroupDetail: { + /** Id */ + id: number; + /** Name */ + name: string; + /** Owner Id */ + owner_id: number; + /** My Role */ + my_role: string; + /** + * Expansion Ids + * @default [] + */ + expansion_ids: number[]; + }; + /** GroupExpansionsUpdate */ + GroupExpansionsUpdate: { + /** + * Expansion Ids + * @default [] + */ + expansion_ids: number[]; + }; + /** GroupRename */ + GroupRename: { + /** Name */ + name: string; + }; + /** GroupStats */ + GroupStats: { + /** Group Id */ + group_id: number; + /** Total Matches */ + total_matches: number; + /** Last Match At */ + last_match_at?: string | null; + /** + * Leaderboard + * @default [] + */ + leaderboard: components["schemas"]["LeaderboardEntry"][]; + /** + * Provisional + * @default [] + */ + provisional: components["schemas"]["LeaderboardEntry"][]; + /** + * Faction Meta + * @default [] + */ + faction_meta: components["schemas"]["FactionMeta"][]; + /** Min Games */ + min_games: number; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** HomeInProgressMatch */ + HomeInProgressMatch: { + /** Id */ + id: number; + /** Group Id */ + group_id: number; + /** Group Name */ + group_name: string; + /** Started At */ + started_at?: string | null; + /** Player Count */ + player_count: number; + /** + * Participants + * @default [] + */ + participants: components["schemas"]["MatchListParticipant"][]; + }; + /** HomeResponse */ + HomeResponse: { + /** + * Leaderboard + * @default [] + */ + leaderboard: components["schemas"]["LeaderboardEntry"][]; + /** + * Provisional + * @default [] + */ + provisional: components["schemas"]["LeaderboardEntry"][]; + profile: components["schemas"]["ProfileStats"]; + active_group?: components["schemas"]["GroupBriefStats"] | null; + /** + * In Progress + * @default [] + */ + in_progress: components["schemas"]["HomeInProgressMatch"][]; + /** Min Games */ + min_games: number; + }; + /** Leaderboard */ + Leaderboard: { + /** + * Entries + * @default [] + */ + entries: components["schemas"]["LeaderboardEntry"][]; + /** + * Provisional + * @default [] + */ + provisional: components["schemas"]["LeaderboardEntry"][]; + /** Min Games */ + min_games: number; + }; + /** LeaderboardEntry */ + LeaderboardEntry: { + /** Games */ + games: number; + /** Wins */ + wins: number; + /** Win Rate */ + win_rate: number; + /** Avg Place */ + avg_place?: number | null; + /** Score */ + score?: number | null; + /** User Id */ + user_id: number; + /** Nickname */ + nickname: string; + /** Rank */ + rank?: number | null; + }; + /** MatchCreate */ + MatchCreate: { + /** Group Id */ + group_id: number; + /** Participants */ + participants: components["schemas"]["RosterParticipant"][]; + }; + /** MatchFinish */ + MatchFinish: { + /** Participants */ + participants: components["schemas"]["MatchFinishParticipant"][]; + /** + * Win Reason + * @enum {string} + */ + win_reason: "objectives" | "worlds" | "plastic" | "resources"; + /** Overall Comment */ + overall_comment?: string | null; + }; + /** MatchFinishParticipant */ + MatchFinishParticipant: { + /** User Id */ + user_id: number; + /** Place */ + place: number; + /** Comment */ + comment?: string | null; + /** Faction Id */ + faction_id?: number | null; + }; + /** MatchList */ + MatchList: { + /** + * Items + * @default [] + */ + items: components["schemas"]["MatchListItem"][]; + /** Total */ + total: number; + /** Limit */ + limit: number; + /** Offset */ + offset: number; + }; + /** MatchListItem */ + MatchListItem: { + /** Id */ + id: number; + /** Status */ + status: string; + /** Played At */ + played_at: string; + /** Started At */ + started_at?: string | null; + /** Finished At */ + finished_at?: string | null; + /** Duration Minutes */ + duration_minutes?: number | null; + /** Win Reason */ + win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + /** Player Count */ + player_count: number; + /** Overall Comment */ + overall_comment?: string | null; + /** Created By */ + created_by: number; + /** + * Participants + * @default [] + */ + participants: components["schemas"]["MatchListParticipant"][]; + }; + /** MatchListParticipant */ + MatchListParticipant: { + /** User Id */ + user_id: number; + /** Nickname */ + nickname: string; + /** Faction Id */ + faction_id: number; + /** Faction Name */ + faction_name: string; + /** Place */ + place?: number | null; + /** Was Random */ + was_random: boolean; + /** Comment */ + comment?: string | null; + }; + /** MatchParticipantRead */ + MatchParticipantRead: { + /** User Id */ + user_id: number; + /** Nickname */ + nickname: string; + /** Faction Id */ + faction_id: number; + /** Faction Name */ + faction_name: string; + /** Place */ + place?: number | null; + /** Was Random */ + was_random: boolean; + /** Comment */ + comment?: string | null; + }; + /** MatchRead */ + MatchRead: { + /** Id */ + id: number; + /** Group Id */ + group_id: number; + /** Status */ + status: string; + /** + * Played At + * Format: date + */ + played_at: string; + /** Started At */ + started_at?: string | null; + /** Finished At */ + finished_at?: string | null; + /** Duration Minutes */ + duration_minutes?: number | null; + /** Win Reason */ + win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + /** Player Count */ + player_count: number; + /** Overall Comment */ + overall_comment?: string | null; + /** Created By */ + created_by: number; + /** + * Can Modify + * @default false + */ + can_modify: boolean; + /** + * Participants + * @default [] + */ + participants: components["schemas"]["MatchParticipantRead"][]; + /** + * Attachments + * @default [] + */ + attachments: unknown[]; + }; + /** MatchUpdate */ + MatchUpdate: { + /** Played At */ + played_at?: string | null; + /** Overall Comment */ + overall_comment?: string | null; + /** Win Reason */ + win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + /** Participants */ + participants?: components["schemas"]["ParticipantInput"][] | null; + }; + /** MeRead */ + MeRead: { + /** Id */ + id: number; + /** Nickname */ + nickname: string; + /** Role */ + role: string; + /** Auth Provider */ + auth_provider: string; + /** Telegram Id */ + telegram_id?: number | null; + /** Active Group Id */ + active_group_id?: number | null; + /** + * Groups + * @default [] + */ + groups: components["schemas"]["GroupBrief"][]; + }; + /** MemberAdd */ + MemberAdd: { + /** Nickname */ + nickname: string; + }; + /** MemberRead */ + MemberRead: { + /** User Id */ + user_id: number; + /** Nickname */ + nickname: string; + /** Role */ + role: string; + }; + /** MemberRoleUpdate */ + MemberRoleUpdate: { + /** Role */ + role: string; + }; + /** NicknameAvailable */ + NicknameAvailable: { + /** Available */ + available: boolean; + }; + /** NicknameUpdate */ + NicknameUpdate: { + /** Nickname */ + nickname: string; + }; + /** OkResponse */ + OkResponse: { + /** + * Ok + * @default true + */ + ok: boolean; + }; + /** OverallStats */ + OverallStats: { + /** Games */ + games: number; + /** Wins */ + wins: number; + /** Win Rate */ + win_rate: number; + /** Avg Place */ + avg_place?: number | null; + /** Score */ + score?: number | null; + }; + /** ParticipantInput */ + ParticipantInput: { + /** User Id */ + user_id: number; + /** Faction Id */ + faction_id: number; + /** Place */ + place: number; + /** + * Was Random + * @default false + */ + was_random: boolean; + /** Comment */ + comment?: string | null; + }; + /** ProfileStats */ + ProfileStats: { + /** User Id */ + user_id: number; + overall: components["schemas"]["OverallStats"]; + /** + * Factions + * @default [] + */ + factions: components["schemas"]["FactionStat"][]; + best_faction?: components["schemas"]["FactionStat"] | null; + worst_faction?: components["schemas"]["FactionStat"] | null; + most_played_faction?: components["schemas"]["FactionStat"] | null; + /** + * Recent Form + * @default [] + */ + recent_form: components["schemas"]["RecentFormItem"][]; + }; + /** RandomizeRequest */ + RandomizeRequest: { + /** Group Id */ + group_id: number; + /** + * Exclude Faction Ids + * @default [] + */ + exclude_faction_ids: number[]; + }; + /** RandomizeResponse */ + RandomizeResponse: { + faction: components["schemas"]["FactionRead"]; + }; + /** RecentFormItem */ + RecentFormItem: { + /** Place */ + place: number; + /** Player Count */ + player_count: number; + /** Played At */ + played_at: string; + }; + /** RosterParticipant */ + RosterParticipant: { + /** User Id */ + user_id: number; + /** Faction Id */ + faction_id: number; + /** + * Was Random + * @default false + */ + was_random: boolean; + }; + /** TelegramAuthPayload */ + TelegramAuthPayload: { + /** Id */ + id: number; + /** Auth Date */ + auth_date: number; + /** Hash */ + hash: string; + /** First Name */ + first_name?: string | null; + /** Last Name */ + last_name?: string | null; + /** Username */ + username?: string | null; + /** Photo Url */ + photo_url?: string | null; + } & { + [key: string]: unknown; + }; + /** UserRead */ + UserRead: { + /** Id */ + id: number; + /** Nickname */ + nickname: string; + /** Role */ + role: string; + /** Auth Provider */ + auth_provider: string; + /** Telegram Id */ + telegram_id?: number | null; + /** Active Group Id */ + active_group_id?: number | null; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + auth_config_api_auth_config_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"]["AuthConfig"]; + }; + }; + }; + }; + telegram_login_api_auth_telegram_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TelegramAuthPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + logout_api_auth_logout_post: { + 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"]["OkResponse"]; + }; + }; + }; + }; + get_me_api_users_me_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"]["MeRead"]; + }; + }; + }; + }; + update_me_api_users_me_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NicknameUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + my_stats_api_users_me_stats_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"]["ProfileStats"]; + }; + }; + }; + }; + set_active_group_api_users_me_active_group_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ActiveGroupUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + nickname_available_api_users_nickname_available_get: { + parameters: { + query: { + value: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NicknameAvailable"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + my_groups_api_groups_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"]["GroupBrief"][]; + }; + }; + }; + }; + create_group_api_groups_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GroupCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_group_api_groups__group_id__get: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + rename_group_api_groups__group_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GroupRename"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_expansions_api_groups__group_id__expansions_put: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GroupExpansionsUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + group_factions_api_groups__group_id__factions_get: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FactionRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_members_api_groups__group_id__members_get: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MemberRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + add_member_api_groups__group_id__members_post: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberAdd"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MemberRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + remove_member_api_groups__group_id__members__user_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + user_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"]; + }; + }; + }; + }; + change_member_role_api_groups__group_id__members__user_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + user_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberRoleUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MemberRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + group_matches_api_groups__group_id__matches_get: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MatchList"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + group_stats_api_groups__group_id__stats_get: { + parameters: { + query?: never; + header?: never; + path: { + group_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupStats"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + randomize_faction_api_matches_randomize_faction_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RandomizeRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RandomizeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + start_match_api_matches_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MatchCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MatchRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + finish_match_api_matches__match_id__finish_post: { + parameters: { + query?: never; + header?: never; + path: { + match_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MatchFinish"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MatchRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_match_api_matches__match_id__get: { + parameters: { + query?: never; + header?: never; + path: { + match_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MatchRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_match_api_matches__match_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + match_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"]; + }; + }; + }; + }; + update_match_api_matches__match_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + match_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MatchUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MatchRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_expansions_api_expansions_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"]["ExpansionRead"][]; + }; + }; + }; + }; + list_factions_api_factions_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"]["FactionRead"][]; + }; + }; + }; + }; + overall_leaderboard_api_stats_leaderboard_get: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Leaderboard"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + home_api_home_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"]["HomeResponse"]; + }; + }; + }; + }; + admin_login_api_admin_auth_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminLogin"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminMe"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_logout_api_admin_auth_logout_post: { + 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"]["OkResponse"]; + }; + }; + }; + }; + admin_me_api_admin_me_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"]["AdminMe"]; + }; + }; + }; + }; + list_users_api_admin_users_get: { + parameters: { + query?: { + query?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_user_api_admin_users__user_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + user_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"]; + }; + }; + }; + }; + update_user_api_admin_users__user_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_groups_api_admin_groups_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"]["AdminGroupRead"][]; + }; + }; + }; + }; + delete_group_api_admin_groups__group_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + group_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"]; + }; + }; + }; + }; + list_matches_api_admin_matches_get: { + parameters: { + query?: { + group_id?: number | null; + user_id?: number | null; + faction_id?: number | null; + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminMatchRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_match_api_admin_matches__match_id__get: { + parameters: { + query?: never; + header?: never; + path: { + match_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MatchRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_match_api_admin_matches__match_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + match_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"]; + }; + }; + }; + }; + update_match_api_admin_matches__match_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + match_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MatchUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MatchRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_factions_api_admin_factions_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"]["FactionRead"][]; + }; + }; + }; + }; + rename_faction_api_admin_factions__faction_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + faction_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FactionRename"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FactionRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + audit_logs_api_admin_audit_logs_get: { + parameters: { + query?: { + action?: string | null; + entity_type?: string | null; + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuditLogList"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + dev_login_api_auth_dev_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DevLogin"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + dev_list_users_api_auth_dev_users_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"]["DevUserRead"][]; + }; + }; + }; + }; + dev_create_user_api_auth_dev_users_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DevUserCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DevUserRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + health_api_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + }; + }; +} diff --git a/frontend/src/app/queryClient.ts b/frontend/src/app/queryClient.ts new file mode 100644 index 0000000..e6ec224 --- /dev/null +++ b/frontend/src/app/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + }, +}); diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx new file mode 100644 index 0000000..6fdabde --- /dev/null +++ b/frontend/src/app/router.tsx @@ -0,0 +1,68 @@ +import { Navigate, createBrowserRouter } from "react-router-dom"; + +import { AppShell } from "../components/AppShell"; +import { RequireAdmin, RequireAuth, RequireGroup } from "../auth/guards"; +import { AccountPage } from "../pages/AccountPage"; +import { CreateMatchPage } from "../pages/CreateMatchPage"; +import { GroupPage } from "../pages/GroupPage"; +import { GroupSettingsPage } from "../pages/GroupSettingsPage"; +import { HomePage } from "../pages/HomePage"; +import { LoginPage } from "../pages/LoginPage"; +import { MatchDetailPage } from "../pages/MatchDetailPage"; +import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage"; +import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage"; +import { AdminLayout } from "../pages/admin/AdminLayout"; +import { AdminLogsPage } from "../pages/admin/AdminLogsPage"; +import { AdminLoginPage } from "../pages/admin/AdminLoginPage"; +import { AdminRecordsPage } from "../pages/admin/AdminRecordsPage"; + +export const router = createBrowserRouter([ + { path: "/login", element: }, + { path: "/admin/login", element: }, + { + path: "/", + element: ( + + + + ), + children: [ + { index: true, element: }, + { + path: "match/new", + element: ( + + + + ), + }, + { path: "match/:matchId", element: }, + { path: "group", element: }, + { + path: "group/settings", + element: ( + + + + ), + }, + { path: "account", element: }, + ], + }, + { + path: "/admin", + element: ( + + + + ), + children: [ + { index: true, element: }, + { path: "records", element: }, + { path: "accounts", element: }, + { path: "factions", element: }, + { path: "logs", element: }, + ], + }, + { path: "*", element: }, +]); diff --git a/frontend/src/auth/guards.tsx b/frontend/src/auth/guards.tsx new file mode 100644 index 0000000..a468f42 --- /dev/null +++ b/frontend/src/auth/guards.tsx @@ -0,0 +1,29 @@ +import type { PropsWithChildren } from "react"; +import { Navigate, useLocation } from "react-router-dom"; + +import { Spinner } from "../components/Spinner"; +import { useMe } from "../hooks/auth"; +import { useAdminMe } from "../hooks/admin"; + +export function RequireAuth({ children }: PropsWithChildren) { + const { data: me, isLoading } = useMe(); + const location = useLocation(); + if (isLoading) return ; + if (!me) return ; + return <>{children}; +} + +export function RequireGroup({ children }: PropsWithChildren) { + const { data: me, isLoading } = useMe(); + if (isLoading) return ; + if (!me) return ; + if (me.active_group_id == null) return ; + return <>{children}; +} + +export function RequireAdmin({ children }: PropsWithChildren) { + const { data: admin, isLoading } = useAdminMe(); + if (isLoading) return ; + if (!admin) return ; + return <>{children}; +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..0dcea79 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,39 @@ +import { Outlet, useLocation, useNavigate } from "react-router-dom"; + +import { BottomBar } from "./BottomBar"; + +const TITLES: Record = { + "/": "Forbidden Stars", + "/group": "Группа", + "/group/settings": "Настройки группы", + "/account": "Аккаунт", + "/match/new": "Новая партия", +}; + +export function AppShell() { + const location = useLocation(); + const navigate = useNavigate(); + const title = TITLES[location.pathname] ?? "Forbidden Stars"; + const isHome = location.pathname === "/"; + + return ( +
+
+ {isHome ? ( + {title} + ) : ( + + )} + {!isHome ? title : ""} +
+ +
+ +
+ + +
+ ); +} diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx new file mode 100644 index 0000000..25d6a4c --- /dev/null +++ b/frontend/src/components/BottomBar.tsx @@ -0,0 +1,47 @@ +import { Plus, Settings, Users } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { useToast } from "../context/ToastContext"; +import { useMe } from "../hooks/auth"; + +export function BottomBar() { + const { data: me } = useMe(); + const navigate = useNavigate(); + const toast = useToast(); + const hasGroup = me?.active_group_id != null; + const activeGroup = me?.groups.find((g) => g.id === me?.active_group_id); + + const onCreate = () => { + if (!hasGroup) { + toast.show("Вы не состоите в группе. Создайте или вступите в группу."); + navigate("/group"); + return; + } + navigate("/match/new"); + }; + + return ( + + ); +} diff --git a/frontend/src/components/CreateGroupForm.tsx b/frontend/src/components/CreateGroupForm.tsx new file mode 100644 index 0000000..9e40252 --- /dev/null +++ b/frontend/src/components/CreateGroupForm.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; + +import { ApiError } from "../api/client"; +import { useToast } from "../context/ToastContext"; +import { useCreateGroup } from "../hooks/groups"; +import { useExpansions } from "../hooks/reference"; + +export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) { + const { data: expansions } = useExpansions(); + const createGroup = useCreateGroup(); + const toast = useToast(); + const [name, setName] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [error, setError] = useState(null); + + const toggle = (id: number) => + setSelected((s) => { + const next = new Set(s); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const submit = async () => { + setError(null); + try { + await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] }); + toast.show("Группа создана"); + onCreated?.(); + } catch (e) { + setError(e instanceof ApiError ? e.message : "Не удалось создать группу"); + } + }; + + return ( +
+

Новая группа

+
+ + setName(e.target.value)} placeholder="Например: Вечер 40k" /> +
+
+ +
+ {(expansions ?? []).map((e) => ( + + ))} +
+
+ {error &&

{error}

} + +
+ ); +} diff --git a/frontend/src/components/GroupSwitcher.tsx b/frontend/src/components/GroupSwitcher.tsx new file mode 100644 index 0000000..eaa5df8 --- /dev/null +++ b/frontend/src/components/GroupSwitcher.tsx @@ -0,0 +1,27 @@ +import { useMe, useSetActiveGroup } from "../hooks/auth"; + +export function GroupSwitcher() { + const { data: me } = useMe(); + const setActive = useSetActiveGroup(); + if (!me || me.groups.length === 0) return null; + + return ( +
+

Мои группы

+
+ {me.groups.map((g) => { + const active = g.id === me.active_group_id; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/InProgressMatches.tsx b/frontend/src/components/InProgressMatches.tsx new file mode 100644 index 0000000..bb9917b --- /dev/null +++ b/frontend/src/components/InProgressMatches.tsx @@ -0,0 +1,53 @@ +import { ChevronRight } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { formatTime } from "../domain/format"; +import type { HomeInProgressMatch } from "../domain/types"; + +export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) { + const navigate = useNavigate(); + if (items.length === 0) return null; + + return ( +
+

Незавершённые партии

+
+ {items.map((m) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx new file mode 100644 index 0000000..66bea59 --- /dev/null +++ b/frontend/src/components/Leaderboard.tsx @@ -0,0 +1,53 @@ +import type { LeaderboardEntry } from "../domain/types"; + +function pct(v: number | null | undefined): string { + return v == null ? "—" : `${Math.round(v * 100)}%`; +} + +function Row({ entry }: { entry: LeaderboardEntry }) { + return ( +
+
+ {entry.rank ?? "—"} +
+
+
{entry.nickname}
+
+ Игр: {entry.games} + Побед: {entry.wins} + WR: {pct(entry.win_rate)} +
+
+
{entry.score ?? "—"}
+
+ ); +} + +export function Leaderboard({ + entries, + provisional, +}: { + entries: LeaderboardEntry[]; + provisional?: LeaderboardEntry[]; +}) { + if (entries.length === 0 && (!provisional || provisional.length === 0)) { + return
Пока нет сыгранных партий.
; + } + return ( +
+ {entries.map((e) => ( + + ))} + {provisional && provisional.length > 0 && ( + <> +
+ Новички (мало игр) +
+ {provisional.map((e) => ( + + ))} + + )} +
+ ); +} diff --git a/frontend/src/components/MatchList.tsx b/frontend/src/components/MatchList.tsx new file mode 100644 index 0000000..3b9493e --- /dev/null +++ b/frontend/src/components/MatchList.tsx @@ -0,0 +1,49 @@ +import { useNavigate } from "react-router-dom"; + +import { formatDuration } from "../domain/format"; +import { winReasonLabel } from "../domain/winReasons"; +import type { MatchListItem } from "../domain/types"; + +export function MatchListView({ items }: { items: MatchListItem[] }) { + const navigate = useNavigate(); + if (items.length === 0) return
Партий пока нет.
; + + return ( +
+ {items.map((m) => { + const inProgress = m.status === "in_progress"; + const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99)); + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx new file mode 100644 index 0000000..2f9022f --- /dev/null +++ b/frontend/src/components/ProfileStatsCard.tsx @@ -0,0 +1,40 @@ +import type { ProfileStats } from "../domain/types"; + +function pct(v: number | null | undefined): string { + return v == null ? "—" : `${Math.round(v * 100)}%`; +} + +export function ProfileStatsCard({ stats }: { stats: ProfileStats }) { + const o = stats.overall; + return ( +
+

Моя статистика

+
+
Игр
+ {o.games} +
+
+
Побед
+ {o.wins} +
+
+
Winrate
+ {pct(o.win_rate)} +
+
+
Среднее место
+ {o.avg_place ?? "—"} +
+
+
Очки (рейтинг)
+ {o.score ?? "—"} +
+ {stats.most_played_faction && ( +
+
Любимая фракция
+ {stats.most_played_faction.name_ru} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Spinner.tsx b/frontend/src/components/Spinner.tsx new file mode 100644 index 0000000..51f97f9 --- /dev/null +++ b/frontend/src/components/Spinner.tsx @@ -0,0 +1,19 @@ +export function Spinner({ label }: { label?: string }) { + return ( +
+
+ {label ?? "Загрузка…"} + +
+ ); +} diff --git a/frontend/src/components/TelegramLoginButton.tsx b/frontend/src/components/TelegramLoginButton.tsx new file mode 100644 index 0000000..a18f9c3 --- /dev/null +++ b/frontend/src/components/TelegramLoginButton.tsx @@ -0,0 +1,52 @@ +import { useEffect, useRef } from "react"; + +interface TelegramUser { + id: number; + first_name?: string; + last_name?: string; + username?: string; + photo_url?: string; + auth_date: number; + hash: string; +} + +declare global { + interface Window { + __fsTelegramAuth?: (user: TelegramUser) => void; + } +} + +/** + * Официальный Telegram Login Widget. Требует бота (@BotFather) и /setdomain + * на текущий HTTPS-домен. botUsername — без «@». + */ +export function TelegramLoginButton({ + botUsername, + onAuth, +}: { + botUsername: string; + onAuth: (user: TelegramUser) => void; +}) { + const ref = useRef(null); + + useEffect(() => { + window.__fsTelegramAuth = (user) => onAuth(user); + const script = document.createElement("script"); + script.src = "https://telegram.org/js/telegram-widget.js?22"; + script.async = true; + script.setAttribute("data-telegram-login", botUsername); + script.setAttribute("data-size", "large"); + script.setAttribute("data-userpic", "false"); + script.setAttribute("data-request-access", "write"); + script.setAttribute("data-onauth", "__fsTelegramAuth(user)"); + + const node = ref.current; + node?.appendChild(script); + return () => { + if (node) node.innerHTML = ""; + delete window.__fsTelegramAuth; + }; + }, [botUsername, onAuth]); + + return
; +} diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx new file mode 100644 index 0000000..ccb0969 --- /dev/null +++ b/frontend/src/context/ToastContext.tsx @@ -0,0 +1,30 @@ +import { createContext, useCallback, useContext, useRef, useState } from "react"; +import type { PropsWithChildren } from "react"; + +interface ToastCtx { + show: (message: string) => void; +} + +const Ctx = createContext({ show: () => {} }); + +export function ToastProvider({ children }: PropsWithChildren) { + const [message, setMessage] = useState(null); + const timer = useRef | null>(null); + + const show = useCallback((msg: string) => { + setMessage(msg); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setMessage(null), 2800); + }, []); + + return ( + + {children} + {message &&
{message}
} +
+ ); +} + +export function useToast(): ToastCtx { + return useContext(Ctx); +} diff --git a/frontend/src/domain/constants.ts b/frontend/src/domain/constants.ts new file mode 100644 index 0000000..9d4289d --- /dev/null +++ b/frontend/src/domain/constants.ts @@ -0,0 +1,3 @@ +// Лимиты, зеркалят значения бэкенда (авторитетная проверка — на сервере). +export const MAX_MATCH_PLAYERS = 6; +export const MAX_GROUP_SIZE = 10; diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts new file mode 100644 index 0000000..a9f93b8 --- /dev/null +++ b/frontend/src/domain/format.ts @@ -0,0 +1,39 @@ +// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера. +const APP_TZ_OFFSET_MIN = 3 * 60; + +const p2 = (n: number) => String(n).padStart(2, "0"); + +// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3 +// и форматируем по UTC-частям, чтобы получить «настенное» время МСК. +function shifted(iso: string): Date { + return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000); +} + +export function formatDuration(minutes: number | null | undefined): string { + if (minutes == null) return "—"; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h && m) return `≈ ${h} ч ${m} мин`; + if (h) return `≈ ${h} ч`; + return `≈ ${m} мин`; +} + +export function formatTime(iso: string | null | undefined): string { + if (!iso) return "—"; + try { + const d = shifted(iso); + return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; + } catch { + return iso; + } +} + +export function formatDateTime(iso: string | null | undefined): string { + if (!iso) return "—"; + try { + const d = shifted(iso); + return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; + } catch { + return iso; + } +} diff --git a/frontend/src/domain/types.ts b/frontend/src/domain/types.ts new file mode 100644 index 0000000..98e6b6f --- /dev/null +++ b/frontend/src/domain/types.ts @@ -0,0 +1,31 @@ +import type { components } from "../api/schema"; + +type S = components["schemas"]; + +export type Me = S["MeRead"]; +export type UserRead = S["UserRead"]; +export type GroupBrief = S["GroupBrief"]; +export type GroupDetail = S["GroupDetail"]; +export type ExpansionRead = S["ExpansionRead"]; +export type FactionRead = S["FactionRead"]; +export type MemberRead = S["MemberRead"]; +export type MatchRead = S["MatchRead"]; +export type MatchList = S["MatchList"]; +export type MatchListItem = S["MatchListItem"]; +export type Leaderboard = S["Leaderboard"]; +export type LeaderboardEntry = S["LeaderboardEntry"]; +export type ProfileStats = S["ProfileStats"]; +export type GroupStats = S["GroupStats"]; +export type HomeResponse = S["HomeResponse"]; +export type HomeInProgressMatch = S["HomeInProgressMatch"]; +export type FactionStat = S["FactionStat"]; +export type AuthConfig = S["AuthConfig"]; +export type AdminMe = S["AdminMe"]; +export type AdminUserRead = S["AdminUserRead"]; +export type AdminGroupRead = S["AdminGroupRead"]; +export type AdminMatchRead = S["AdminMatchRead"]; +export type AuditLogList = S["AuditLogList"]; +export type ParticipantInput = S["ParticipantInput"]; +export type MatchUpdate = S["MatchUpdate"]; +export type MatchCreate = S["MatchCreate"]; +export type MatchFinish = S["MatchFinish"]; diff --git a/frontend/src/domain/winReasons.ts b/frontend/src/domain/winReasons.ts new file mode 100644 index 0000000..048e46c --- /dev/null +++ b/frontend/src/domain/winReasons.ts @@ -0,0 +1,19 @@ +import type { components } from "../api/schema"; + +export type WinReason = components["schemas"]["MatchFinish"]["win_reason"]; + +// Фиксированный порядок (сверху вниз). +export const WIN_REASONS: { code: WinReason; label: string }[] = [ + { code: "objectives", label: "По целям" }, + { code: "worlds", label: "По мирам" }, + { code: "plastic", label: "По пластику" }, + { code: "resources", label: "По ресурсам" }, +]; + +const LABELS: Record = Object.fromEntries( + WIN_REASONS.map((w) => [w.code, w.label]), +); + +export function winReasonLabel(code: string | null | undefined): string { + return code ? LABELS[code] ?? code : "—"; +} diff --git a/frontend/src/hooks/admin.ts b/frontend/src/hooks/admin.ts new file mode 100644 index 0000000..ad9c882 --- /dev/null +++ b/frontend/src/hooks/admin.ts @@ -0,0 +1,165 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api, unwrap } from "../api/client"; +import { qk } from "../api/queryKeys"; +import type { AdminMe, MatchUpdate } from "../domain/types"; + +export function useAdminMe() { + return useQuery({ + queryKey: qk.adminMe, + queryFn: async (): Promise => { + const r = await api.GET("/api/admin/me"); + if (r.response.status === 401 || r.response.status === 403) return null; + return unwrap(r); + }, + }); +} + +export function useAdminLogin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { username: string; password: string }) => + unwrap(await api.POST("/api/admin/auth/login", { body })), + onSuccess: (me) => qc.setQueryData(qk.adminMe, me), + }); +} + +export function useAdminLogout() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => unwrap(await api.POST("/api/admin/auth/logout")), + onSuccess: () => qc.setQueryData(qk.adminMe, null), + }); +} + +export function useAdminUsers(query?: string) { + return useQuery({ + queryKey: [...qk.adminUsers, query ?? ""], + queryFn: async () => + unwrap( + await api.GET("/api/admin/users", { + params: { query: query ? { query } : {} }, + }), + ), + }); +} + +export function useAdminGroups() { + return useQuery({ + queryKey: qk.adminGroups, + queryFn: async () => unwrap(await api.GET("/api/admin/groups")), + }); +} + +export interface AdminMatchFilters { + group_id?: number | null; + user_id?: number | null; + faction_id?: number | null; +} + +export function useAdminMatches(filters: AdminMatchFilters = {}) { + const query: Record = {}; + if (filters.group_id != null) query.group_id = filters.group_id; + if (filters.user_id != null) query.user_id = filters.user_id; + if (filters.faction_id != null) query.faction_id = filters.faction_id; + return useQuery({ + queryKey: [...qk.adminMatches, query], + queryFn: async () => + unwrap(await api.GET("/api/admin/matches", { params: { query } })), + }); +} + +export function useAdminMatch(matchId: number | null) { + return useQuery({ + queryKey: ["adminMatch", matchId], + enabled: matchId != null, + queryFn: async () => + unwrap( + await api.GET("/api/admin/matches/{match_id}", { + params: { path: { match_id: matchId as number } }, + }), + ), + }); +} + +export function useAdminUpdateMatch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { matchId: number; body: MatchUpdate }) => + unwrap( + await api.PATCH("/api/admin/matches/{match_id}", { + params: { path: { match_id: args.matchId } }, + body: args.body, + }), + ), + onSuccess: (_d, args) => { + qc.invalidateQueries({ queryKey: qk.adminMatches }); + qc.invalidateQueries({ queryKey: ["adminMatch", args.matchId] }); + }, + }); +} + +export function useAdminFactions() { + return useQuery({ + queryKey: ["adminFactions"], + queryFn: async () => unwrap(await api.GET("/api/admin/factions")), + }); +} + +export function useAdminRenameFaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { factionId: number; name_ru: string }) => + unwrap( + await api.PATCH("/api/admin/factions/{faction_id}", { + params: { path: { faction_id: args.factionId } }, + body: { name_ru: args.name_ru }, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: ["adminFactions"] }), + }); +} + +export function useAdminLogs(filters?: { action?: string; entity_type?: string }) { + return useQuery({ + queryKey: [...qk.adminLogs, filters?.action ?? "", filters?.entity_type ?? ""], + queryFn: async () => + unwrap( + await api.GET("/api/admin/audit-logs", { + params: { + query: { + ...(filters?.action ? { action: filters.action } : {}), + ...(filters?.entity_type ? { entity_type: filters.entity_type } : {}), + }, + }, + }), + ), + }); +} + +export function useAdminDeleteMatch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (matchId: number) => + unwrap( + await api.DELETE("/api/admin/matches/{match_id}", { + params: { path: { match_id: matchId } }, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminMatches }), + }); +} + +export function useAdminUpdateUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { userId: number; nickname?: string; is_active?: boolean }) => + unwrap( + await api.PATCH("/api/admin/users/{user_id}", { + params: { path: { user_id: args.userId } }, + body: { nickname: args.nickname, is_active: args.is_active }, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }), + }); +} diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts new file mode 100644 index 0000000..9948e11 --- /dev/null +++ b/frontend/src/hooks/auth.ts @@ -0,0 +1,101 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api, unwrap } from "../api/client"; +import { qk } from "../api/queryKeys"; +import type { AuthConfig, Me } from "../domain/types"; + +export function useMe() { + return useQuery({ + queryKey: qk.me, + queryFn: async (): Promise => { + const r = await api.GET("/api/users/me"); + if (r.response.status === 401) return null; + return unwrap(r); + }, + }); +} + +export function useAuthConfig() { + return useQuery({ + queryKey: qk.authConfig, + queryFn: async (): Promise => unwrap(await api.GET("/api/auth/config")), + }); +} + +export function useDevUsers(enabled: boolean) { + return useQuery({ + queryKey: qk.devUsers, + enabled, + queryFn: async () => unwrap(await api.GET("/api/auth/dev/users")), + }); +} + +// DEV-вход по нику (только в деве; в прод-бэкенде эндпоинта нет). +export function useStubLogin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (nickname: string) => + unwrap(await api.POST("/api/auth/dev/login", { body: { nickname } })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + +// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC. +export function useTelegramLogin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (payload: Record) => + unwrap(await api.POST("/api/auth/telegram", { body: payload as never })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + +export function useCreateDevUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (nickname: string) => + unwrap(await api.POST("/api/auth/dev/users", { body: { nickname } })), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.devUsers }), + }); +} + +export function useLogout() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => unwrap(await api.POST("/api/auth/logout")), + onSuccess: () => { + qc.setQueryData(qk.me, null); + qc.clear(); + }, + }); +} + +export function useUpdateNickname() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (nickname: string) => + unwrap(await api.PATCH("/api/users/me", { body: { nickname } })), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.me }); + qc.invalidateQueries({ queryKey: qk.home }); + }, + }); +} + +export function useSetActiveGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (group_id: number | null) => + unwrap(await api.PUT("/api/users/me/active-group", { body: { group_id } })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries({ queryKey: qk.home }); + }, + }); +} diff --git a/frontend/src/hooks/groups.ts b/frontend/src/hooks/groups.ts new file mode 100644 index 0000000..c894ebe --- /dev/null +++ b/frontend/src/hooks/groups.ts @@ -0,0 +1,123 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api, unwrap } from "../api/client"; +import { qk } from "../api/queryKeys"; + +export function useMyGroups() { + return useQuery({ + queryKey: qk.groups, + queryFn: async () => unwrap(await api.GET("/api/groups")), + }); +} + +export function useGroup(groupId: number | null) { + return useQuery({ + queryKey: groupId ? qk.group(groupId) : ["group", "none"], + enabled: groupId != null, + queryFn: async () => + unwrap( + await api.GET("/api/groups/{group_id}", { + params: { path: { group_id: groupId as number } }, + }), + ), + }); +} + +export function useGroupMembers(groupId: number | null) { + return useQuery({ + queryKey: groupId ? qk.groupMembers(groupId) : ["group", "none", "members"], + enabled: groupId != null, + queryFn: async () => + unwrap( + await api.GET("/api/groups/{group_id}/members", { + params: { path: { group_id: groupId as number } }, + }), + ), + }); +} + +export function useGroupStats(groupId: number | null) { + return useQuery({ + queryKey: groupId ? qk.groupStats(groupId) : ["group", "none", "stats"], + enabled: groupId != null, + queryFn: async () => + unwrap( + await api.GET("/api/groups/{group_id}/stats", { + params: { path: { group_id: groupId as number } }, + }), + ), + }); +} + +export function useGroupMatches(groupId: number | null, limit = 20, offset = 0) { + return useQuery({ + queryKey: groupId ? [...qk.groupMatches(groupId), limit, offset] : ["group", "none", "matches"], + enabled: groupId != null, + queryFn: async () => + unwrap( + await api.GET("/api/groups/{group_id}/matches", { + params: { path: { group_id: groupId as number }, query: { limit, offset } }, + }), + ), + }); +} + +export function useCreateGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { name: string; expansion_ids: number[] }) => + unwrap(await api.POST("/api/groups", { body })), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.groups }); + qc.invalidateQueries({ queryKey: qk.me }); + qc.invalidateQueries({ queryKey: qk.home }); + }, + }); +} + +export function useSetGroupExpansions(groupId: number) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (expansion_ids: number[]) => + unwrap( + await api.PUT("/api/groups/{group_id}/expansions", { + params: { path: { group_id: groupId } }, + body: { expansion_ids }, + }), + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.group(groupId) }); + qc.invalidateQueries({ queryKey: qk.groupFactions(groupId) }); + }, + }); +} + +export function useAddMember(groupId: number) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (nickname: string) => + unwrap( + await api.POST("/api/groups/{group_id}/members", { + params: { path: { group_id: groupId } }, + body: { nickname }, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) }), + }); +} + +export function useRemoveMember(groupId: number) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (userId: number) => + unwrap( + await api.DELETE("/api/groups/{group_id}/members/{user_id}", { + params: { path: { group_id: groupId, user_id: userId } }, + }), + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) }); + qc.invalidateQueries({ queryKey: qk.me }); + }, + }); +} diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts new file mode 100644 index 0000000..ff8d836 --- /dev/null +++ b/frontend/src/hooks/matches.ts @@ -0,0 +1,75 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api, unwrap } from "../api/client"; +import { qk } from "../api/queryKeys"; +import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types"; + +export function useMatch(matchId: number | null) { + return useQuery({ + queryKey: matchId ? qk.match(matchId) : ["match", "none"], + enabled: matchId != null, + queryFn: async () => + unwrap( + await api.GET("/api/matches/{match_id}", { + params: { path: { match_id: matchId as number } }, + }), + ), + }); +} + +export function useRandomizeFaction() { + return useMutation({ + mutationFn: async (args: { group_id: number; exclude_faction_ids: number[] }): Promise => { + const res = unwrap(await api.POST("/api/matches/randomize-faction", { body: args })); + return res.faction; + }, + }); +} + +/** Этап 1: старт партии (игроки + фракции, без мест). */ +export function useStartMatch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: MatchCreate): Promise => + unwrap(await api.POST("/api/matches", { body })), + onSuccess: (m) => { + qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) }); + qc.invalidateQueries({ queryKey: qk.home }); + }, + }); +} + +/** Этап 2: завершение партии (места, причина победы). */ +export function useFinishMatch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { matchId: number; body: MatchFinish }): Promise => + unwrap( + await api.POST("/api/matches/{match_id}/finish", { + params: { path: { match_id: args.matchId } }, + body: args.body, + }), + ), + onSuccess: (m) => { + qc.invalidateQueries({ queryKey: qk.match(m.id) }); + qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) }); + qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) }); + qc.invalidateQueries({ queryKey: qk.leaderboard }); + qc.invalidateQueries({ queryKey: qk.home }); + qc.invalidateQueries({ queryKey: qk.myStats }); + }, + }); +} + +export function useDeleteMatch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (matchId: number) => + unwrap( + await api.DELETE("/api/matches/{match_id}", { + params: { path: { match_id: matchId } }, + }), + ), + onSuccess: () => qc.invalidateQueries(), + }); +} diff --git a/frontend/src/hooks/reference.ts b/frontend/src/hooks/reference.ts new file mode 100644 index 0000000..7f5133d --- /dev/null +++ b/frontend/src/hooks/reference.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api, unwrap } from "../api/client"; +import { qk } from "../api/queryKeys"; + +export function useExpansions() { + return useQuery({ + queryKey: qk.expansions, + queryFn: async () => unwrap(await api.GET("/api/expansions")), + }); +} + +export function useGroupFactions(groupId: number | null) { + return useQuery({ + queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"], + enabled: groupId != null, + queryFn: async () => + unwrap( + await api.GET("/api/groups/{group_id}/factions", { + params: { path: { group_id: groupId as number } }, + }), + ), + }); +} diff --git a/frontend/src/hooks/stats.ts b/frontend/src/hooks/stats.ts new file mode 100644 index 0000000..169bfe5 --- /dev/null +++ b/frontend/src/hooks/stats.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api, unwrap } from "../api/client"; +import { qk } from "../api/queryKeys"; + +export function useHome() { + return useQuery({ + queryKey: qk.home, + queryFn: async () => unwrap(await api.GET("/api/home")), + }); +} + +export function useLeaderboard(limit = 50) { + return useQuery({ + queryKey: [...qk.leaderboard, limit], + queryFn: async () => + unwrap(await api.GET("/api/stats/leaderboard", { params: { query: { limit } } })), + }); +} + +export function useMyStats() { + return useQuery({ + queryKey: qk.myStats, + queryFn: async () => unwrap(await api.GET("/api/users/me/stats")), + }); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ac9397c --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "./App"; +import "./styles/global.css"; +import "./styles/layout.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx new file mode 100644 index 0000000..fcf2a90 --- /dev/null +++ b/frontend/src/pages/AccountPage.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { ApiError } from "../api/client"; +import { GroupSwitcher } from "../components/GroupSwitcher"; +import { ProfileStatsCard } from "../components/ProfileStatsCard"; +import { Spinner } from "../components/Spinner"; +import { useToast } from "../context/ToastContext"; +import { useLogout, useMe, useUpdateNickname } from "../hooks/auth"; +import { useMyStats } from "../hooks/stats"; + +export function AccountPage() { + const { data: me, isLoading } = useMe(); + const { data: stats } = useMyStats(); + const updateNick = useUpdateNickname(); + const logout = useLogout(); + const toast = useToast(); + const navigate = useNavigate(); + const [nickname, setNickname] = useState(""); + + if (isLoading || !me) return ; + + const save = async () => { + const nick = nickname.trim(); + if (!nick) return; + try { + await updateNick.mutateAsync(nick); + setNickname(""); + toast.show("Никнейм обновлён"); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + + const doLogout = async () => { + await logout.mutateAsync().catch(() => {}); + navigate("/login", { replace: true }); + }; + + return ( +
+
+

Профиль

+
+ Никнейм + {me.nickname} +
+
+ +
+ setNickname(e.target.value)} placeholder={me.nickname} /> + +
+
+
+ + {stats && } + + + + + +

+ +

+
+ ); +} diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx new file mode 100644 index 0000000..c2638bc --- /dev/null +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -0,0 +1,183 @@ +import { Dices, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { ApiError } from "../api/client"; +import { Spinner } from "../components/Spinner"; +import { MAX_MATCH_PLAYERS } from "../domain/constants"; +import { useToast } from "../context/ToastContext"; +import type { MemberRead } from "../domain/types"; +import { useMe } from "../hooks/auth"; +import { useGroupFactions } from "../hooks/reference"; +import { useGroupMembers } from "../hooks/groups"; +import { useRandomizeFaction, useStartMatch } from "../hooks/matches"; + +interface Draft { + user_id: number | ""; + faction_id: number | ""; + was_random: boolean; +} + +function emptyDraft(): Draft { + return { user_id: "", faction_id: "", was_random: false }; +} + +export function CreateMatchPage() { + const { data: me } = useMe(); + const groupId = me?.active_group_id ?? null; + const { data: members, isLoading: lm } = useGroupMembers(groupId); + const { data: factions, isLoading: lf } = useGroupFactions(groupId); + const startMatch = useStartMatch(); + const randomize = useRandomizeFaction(); + const toast = useToast(); + const navigate = useNavigate(); + + const [rows, setRows] = useState([emptyDraft(), emptyDraft()]); + const [error, setError] = useState(null); + + if (lm || lf) return ; + if (!groupId) return
Нет активной группы.
; + + const takenFactionIds = (exceptIdx: number) => + rows + .filter((_, i) => i !== exceptIdx) + .map((r) => r.faction_id) + .filter((x): x is number => typeof x === "number"); + + const update = (idx: number, patch: Partial) => + setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r))); + + const addRow = () => setRows((rs) => [...rs, emptyDraft()]); + const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx)); + + const randomizeRow = async (idx: number) => { + try { + const faction = await randomize.mutateAsync({ + group_id: groupId, + exclude_faction_ids: takenFactionIds(idx), + }); + update(idx, { faction_id: faction.id, was_random: true }); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка рандома"); + } + }; + + const randomizeAll = () => { + const pool = [...(factions ?? [])]; + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы."); + setRows((rs) => + rs.map((r, i) => ({ ...r, faction_id: pool[i % pool.length]?.id ?? "", was_random: true })), + ); + }; + + const submit = async () => { + setError(null); + const filled = rows.filter((r) => r.user_id !== "" && r.faction_id !== ""); + if (filled.length < 2) { + setError("Нужно минимум 2 участника с выбранным игроком и фракцией."); + return; + } + if (filled.length > MAX_MATCH_PLAYERS) { + setError(`Максимум ${MAX_MATCH_PLAYERS} игроков в партии.`); + return; + } + try { + const match = await startMatch.mutateAsync({ + group_id: groupId, + participants: filled.map((r) => ({ + user_id: r.user_id as number, + faction_id: r.faction_id as number, + was_random: r.was_random, + })), + }); + toast.show("Партия начата"); + navigate(`/match/${match.id}`, { replace: true }); + } catch (e) { + setError(e instanceof ApiError ? e.message : "Не удалось начать партию"); + } + }; + + const memberOptions = (members ?? []) as MemberRead[]; + + return ( +
+
+

Этап 1 — старт партии

+

Выберите игроков и фракции. Места и итоги укажете при завершении.

+ +
+ + {rows.map((row, idx) => ( +
+
+ Игрок {idx + 1} + {rows.length > 2 && ( + + )} +
+ + + +
+ + +
+ {row.was_random && row.faction_id !== "" && 🎲 случайная} +
+ ))} + + {rows.length < Math.min(MAX_MATCH_PLAYERS, memberOptions.length) && ( + + )} + + {error &&

{error}

} + + +
+ ); +} diff --git a/frontend/src/pages/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx new file mode 100644 index 0000000..e95482b --- /dev/null +++ b/frontend/src/pages/GroupPage.tsx @@ -0,0 +1,65 @@ +import { useNavigate } from "react-router-dom"; + +import { CreateGroupForm } from "../components/CreateGroupForm"; +import { GroupSwitcher } from "../components/GroupSwitcher"; +import { Leaderboard } from "../components/Leaderboard"; +import { MatchListView } from "../components/MatchList"; +import { Spinner } from "../components/Spinner"; +import { useMe } from "../hooks/auth"; +import { useGroup, useGroupMatches, useGroupStats } from "../hooks/groups"; + +export function GroupPage() { + const { data: me, isLoading } = useMe(); + const navigate = useNavigate(); + const groupId = me?.active_group_id ?? null; + + const { data: group } = useGroup(groupId); + const { data: stats } = useGroupStats(groupId); + const { data: matches } = useGroupMatches(groupId); + + if (isLoading) return ; + + if (!groupId) { + return ( +
+
+

Активная группа не выбрана

+

+ Создайте группу или выберите существующую, чтобы вести учёт партий. +

+
+ + +
+ ); + } + + return ( +
+
+
+

{group?.name ?? "Группа"}

+ {group && ( + + )} +
+

+ Партий: {stats?.total_matches ?? 0} + {stats?.last_match_at ? ` · последняя: ${stats.last_match_at}` : ""} +

+
+ +
+

Топ в группе

+ {stats && } +
+ + + +

Партии

+ {matches && } +
+ ); +} diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx new file mode 100644 index 0000000..1b0dfd2 --- /dev/null +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -0,0 +1,128 @@ +import { Trash2, UserPlus } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { ApiError } from "../api/client"; +import { Spinner } from "../components/Spinner"; +import { MAX_GROUP_SIZE } from "../domain/constants"; +import { useToast } from "../context/ToastContext"; +import { useMe } from "../hooks/auth"; +import { useAddMember, useGroup, useGroupMembers, useRemoveMember, useSetGroupExpansions } from "../hooks/groups"; +import { useExpansions } from "../hooks/reference"; + +export function GroupSettingsPage() { + const { data: me } = useMe(); + const groupId = me?.active_group_id ?? null; + const { data: group, isLoading } = useGroup(groupId); + const { data: expansions } = useExpansions(); + const { data: members } = useGroupMembers(groupId); + const setExpansions = useSetGroupExpansions(groupId ?? 0); + const addMember = useAddMember(groupId ?? 0); + const removeMember = useRemoveMember(groupId ?? 0); + const toast = useToast(); + + const [selected, setSelected] = useState>(new Set()); + const [newNick, setNewNick] = useState(""); + + useEffect(() => { + if (group) setSelected(new Set(group.expansion_ids)); + }, [group]); + + if (isLoading || !group) return ; + // Управление группой доступно всем участникам (создатель лишь создаёт и приглашает). + const isOwner = true; + + const toggle = (id: number) => + setSelected((s) => { + const next = new Set(s); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const saveExpansions = async () => { + try { + await setExpansions.mutateAsync([...selected]); + toast.show("Дополнения сохранены"); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + + const add = async () => { + const nick = newNick.trim(); + if (!nick) return; + try { + await addMember.mutateAsync(nick); + setNewNick(""); + toast.show("Игрок добавлен"); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + + const remove = async (userId: number) => { + try { + await removeMember.mutateAsync(userId); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + + return ( +
+
+

Дополнения

+
+ {(expansions ?? []).map((e) => ( + + ))} +
+ {isOwner && ( + + )} +
+ +
+

Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})

+ {isOwner && (members ?? []).length >= MAX_GROUP_SIZE && ( +

Достигнут максимум участников ({MAX_GROUP_SIZE}).

+ )} + {isOwner && (members ?? []).length < MAX_GROUP_SIZE && ( +
+ setNewNick(e.target.value)} /> + +
+ )} +
+ {(members ?? []).map((m) => ( +
+ + {m.nickname} · {m.role} + + {isOwner && m.role !== "owner" && ( + + )} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..98a8d01 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,37 @@ +import { InProgressMatches } from "../components/InProgressMatches"; +import { Leaderboard } from "../components/Leaderboard"; +import { ProfileStatsCard } from "../components/ProfileStatsCard"; +import { Spinner } from "../components/Spinner"; +import { useHome } from "../hooks/stats"; + +export function HomePage() { + const { data, isLoading, isError } = useHome(); + + if (isLoading) return ; + if (isError || !data) return
Не удалось загрузить данные.
; + + return ( +
+
+

Топ игроков

+ +
+ + + + + + {data.active_group && ( +
+

Активная группа

+
+ {data.active_group.name} + + игр: {data.active_group.games}, побед: {data.active_group.wins} + +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..be1b8d0 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,151 @@ +import { useState } from "react"; +import { Navigate, useNavigate } from "react-router-dom"; + +import { ApiError } from "../api/client"; +import { Spinner } from "../components/Spinner"; +import { TelegramLoginButton } from "../components/TelegramLoginButton"; +import { + useAuthConfig, + useCreateDevUser, + useDevUsers, + useMe, + useStubLogin, + useTelegramLogin, +} from "../hooks/auth"; + +export function LoginPage() { + const { data: me, isLoading } = useMe(); + const { data: config } = useAuthConfig(); + const navigate = useNavigate(); + const [error, setError] = useState(null); + + const methods = config?.methods ?? []; + const hasTelegram = methods.includes("telegram"); + // Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде + // import.meta.env.DEV === false → блок вырезается из бандла). + const showStub = import.meta.env.DEV && methods.includes("stub"); + + const telegram = useTelegramLogin(); + + if (isLoading) return ; + if (me) return ; + + const onTelegram = async (user: Record) => { + setError(null); + try { + await telegram.mutateAsync(user); + navigate("/", { replace: true }); + } catch (e) { + setError(e instanceof ApiError ? e.message : "Не удалось войти через Telegram"); + } + }; + + return ( +
+
+

+ Forbidden Stars +

+

Учёт партий

+ + {hasTelegram && ( +
+

Вход через Telegram

+ {config?.telegram_bot_username ? ( + onTelegram(u as unknown as Record)} + /> + ) : ( +

+ Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN + в .env (бот от @BotFather + /setdomain на ваш домен). +

+ )} +
+ )} + + {showStub && } + + {error &&

{error}

} + +

+ +

+
+
+ ); +} + +// DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке +// LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом. +function DevLogin({ onError }: { onError: (msg: string | null) => void }) { + const { data: devUsers } = useDevUsers(true); + const stubLogin = useStubLogin(); + const createUser = useCreateDevUser(); + const navigate = useNavigate(); + const [nickname, setNickname] = useState(""); + + const doLogin = async (nick: string) => { + onError(null); + try { + await stubLogin.mutateAsync(nick); + navigate("/", { replace: true }); + } catch (e) { + onError(e instanceof ApiError ? e.message : "Не удалось войти"); + } + }; + + const onCreate = async () => { + const nick = nickname.trim(); + if (!nick) return; + try { + await createUser.mutateAsync(nick); + await doLogin(nick); + } catch (e) { + onError(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + + return ( +
+

Тестовый вход (dev)

+
+ +
+ setNickname(e.target.value)} /> + +
+
+ + {devUsers && devUsers.length > 0 && ( + <> +
Или выберите существующего:
+
+ {devUsers.map((u) => ( + + ))} +
+ + )} +
+ ); +} diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx new file mode 100644 index 0000000..e7b37b2 --- /dev/null +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -0,0 +1,212 @@ +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { ApiError } from "../api/client"; +import { Spinner } from "../components/Spinner"; +import { formatDuration, formatTime } from "../domain/format"; +import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; +import { useToast } from "../context/ToastContext"; +import { useDeleteMatch, useFinishMatch, useMatch } from "../hooks/matches"; + +interface FinishRow { + user_id: number; + nickname: string; + faction_name: string; + place: number; + comment: string; +} + +export function MatchDetailPage() { + const { matchId } = useParams(); + const id = matchId ? Number(matchId) : null; + const { data: match, isLoading } = useMatch(id); + const finish = useFinishMatch(); + const del = useDeleteMatch(); + const toast = useToast(); + const navigate = useNavigate(); + + const [rows, setRows] = useState(null); + const [winReason, setWinReason] = useState("objectives"); + const [overall, setOverall] = useState(""); + const [error, setError] = useState(null); + + if (isLoading) return ; + if (!match) return
Партия не найдена.
; + + const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin) + const inProgress = match.status === "in_progress"; + + // Ленивая инициализация строк завершения из участников. + const finishRows: FinishRow[] = + rows ?? + match.participants.map((p, i) => ({ + user_id: p.user_id, + nickname: p.nickname, + faction_name: p.faction_name, + place: i + 1, + comment: p.comment ?? "", + })); + + const upd = (i: number, patch: Partial) => + setRows(finishRows.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); + + const submitFinish = async () => { + if (!id) return; + setError(null); + try { + await finish.mutateAsync({ + matchId: id, + body: { + participants: finishRows.map((r) => ({ + user_id: r.user_id, + place: r.place, + comment: r.comment.trim() || null, + })), + win_reason: winReason, + overall_comment: overall.trim() || null, + }, + }); + toast.show("Партия завершена"); + } catch (e) { + setError(e instanceof ApiError ? e.message : "Не удалось завершить партию"); + } + }; + + const remove = async () => { + if (!id) return; + await del.mutateAsync(id).catch(() => {}); + toast.show(inProgress ? "Партия отменена" : "Партия удалена"); + navigate(-1); + }; + + const sorted = [...match.participants].sort( + (a, b) => (a.place ?? 99) - (b.place ?? 99), + ); + + return ( +
+
+
+

{match.played_at}

+ {inProgress ? ( + идёт + ) : ( + {formatDuration(match.duration_minutes)} + )} +
+
+ Начало: {formatTime(match.started_at)} + {match.finished_at && ` · конец: ${formatTime(match.finished_at)}`} +
+ {!inProgress && ( +
+ Победа: {winReasonLabel(match.win_reason)} +
+ )} + {match.overall_comment &&

{match.overall_comment}

} +
+ + {/* Завершённая партия — результаты */} + {!inProgress && ( +
+

Результаты

+
+ {sorted.map((p) => ( +
+
+ {p.place ?? "—"} + {p.nickname} + · {p.faction_name} + {p.was_random && 🎲} +
+ {p.comment &&
{p.comment}
} +
+ ))} +
+
+ )} + + {/* Незавершённая партия — состав + форма завершения */} + {inProgress && ( + <> +
+

Состав

+
+ {match.participants.map((p) => ( +
+ {p.nickname} + · {p.faction_name} + {p.was_random && 🎲} +
+ ))} +
+
+ + {canModify && ( +
+

Завершение партии

+ {finishRows.map((r, i) => ( +
+
+ {r.nickname} + {r.faction_name} +
+
+
+ + +
+
+ upd(i, { comment: e.target.value })} + /> +
+ ))} + +
+ + +
+ +
+ +