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}
+ ) : (
+ navigate(-1)}>
+ ← Назад
+
+ )}
+ {!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 (
+
+ navigate("/group")}>
+
+ {activeGroup ? activeGroup.name : "Группа"}
+
+
+
+
+ navigate("/account")}>
+
+ Аккаунт
+
+
+
+
+
+
+ );
+}
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) => (
+
+
+ {e.name_ru}
+ {e.is_base && (всегда включено) }
+
+ toggle(e.id)}
+ />
+
+ ))}
+
+
+ {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 (
+ !active && setActive.mutate(g.id)}
+ >
+ {g.name} {active && "✓"} ({g.role})
+
+ );
+ })}
+
+
+ );
+}
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) => (
+
navigate(`/match/${m.id}`)}
+ style={{
+ width: "100%",
+ textAlign: "left",
+ background: "var(--surface-2)",
+ border: "1px solid var(--border)",
+ borderRadius: "var(--radius-sm)",
+ padding: 12,
+ color: "var(--text)",
+ }}
+ >
+
+
+ {m.group_name}
+ идёт
+
+
+ {formatTime(m.started_at)} · {m.player_count} игр.
+
+
+ {m.participants.map((p) => p.nickname).join(", ")}
+
+
+
+ Завершить
+
+
+ ))}
+
+
+ );
+}
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 (
+
navigate(`/match/${m.id}`)}
+ >
+
+ {m.played_at}
+ {inProgress ? (
+ идёт
+ ) : (
+
+ {formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
+
+ )}
+
+
+ {sorted.map((p) => (
+
+
+ {p.place ?? "—"}
+
+ {p.nickname}
+ · {p.faction_name}
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
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 (
+
+
Моя статистика
+
+
+
+
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 &&
}
+
+
+
+
+ Выйти
+
+
+
+ navigate("/admin/login")}
+ >
+ Вход для администратора
+
+
+
+ );
+}
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 && (
+ removeRow(idx)}>
+
+
+ )}
+
+
+
update(idx, { user_id: e.target.value ? Number(e.target.value) : "" })}
+ >
+ — игрок —
+ {memberOptions.map((m) => (
+
+ {m.nickname}
+
+ ))}
+
+
+
+
+ update(idx, {
+ faction_id: e.target.value ? Number(e.target.value) : "",
+ was_random: false,
+ })
+ }
+ style={{ flex: 1 }}
+ >
+ — фракция —
+ {(factions ?? []).map((f) => (
+
+ {f.name_ru}
+
+ ))}
+
+ randomizeRow(idx)} title="Случайная фракция">
+
+
+
+ {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 && (
+ navigate("/group/settings")}>
+ Настройки
+
+ )}
+
+
+ Партий: {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) => (
+
+
+ {e.name_ru}
+ {e.is_base && (всегда включено) }
+
+ toggle(e.id)}
+ />
+
+ ))}
+
+ {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" && (
+ remove(m.user_id)}>
+
+
+ )}
+
+ ))}
+
+
+
+ );
+}
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}
}
+
+
+ navigate("/admin/login")}
+ style={{ opacity: 0.4 }}
+ >
+ ·
+
+
+
+
+ );
+}
+
+// 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) => (
+ doLogin(u.nickname)}
+ >
+ {u.nickname}
+ {!u.is_active && · отключён }
+
+ ))}
+
+ >
+ )}
+
+ );
+}
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, { place: Number(e.target.value) })}>
+ {Array.from({ length: finishRows.length }, (_, k) => k + 1).map((n) => (
+
+ {n}
+
+ ))}
+
+
+
+
upd(i, { comment: e.target.value })}
+ />
+
+ ))}
+
+
+ Причина победы
+ setWinReason(e.target.value as WinReason)}>
+ {WIN_REASONS.map((w) => (
+
+ {w.label}
+
+ ))}
+
+
+
+
+ Комментарий о партии
+
+
+ {error &&
{error}
}
+
+
+ Завершить партию
+
+
+ )}
+ >
+ )}
+
+ {canModify && (
+
+ {inProgress ? "Отменить партию" : "Удалить партию"}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx
new file mode 100644
index 0000000..e8bde0e
--- /dev/null
+++ b/frontend/src/pages/admin/AdminAccountsPage.tsx
@@ -0,0 +1,44 @@
+import { Spinner } from "../../components/Spinner";
+import { useToast } from "../../context/ToastContext";
+import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
+
+export function AdminAccountsPage() {
+ const { data: users, isLoading } = useAdminUsers();
+ const update = useAdminUpdateUser();
+ const toast = useToast();
+
+ if (isLoading) return ;
+
+ return (
+
+
Аккаунты
+
+ {(users ?? []).map((u) => (
+
+
+
+
{u.nickname} {u.role === "admin" &&
admin }
+
+ #{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
+
+
+ {u.role !== "admin" && (
+
{
+ await update
+ .mutateAsync({ userId: u.id, is_active: !u.is_active })
+ .catch(() => {});
+ toast.show("Сохранено");
+ }}
+ >
+ {u.is_active ? "Отключить" : "Включить"}
+
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/admin/AdminFactionsPage.tsx b/frontend/src/pages/admin/AdminFactionsPage.tsx
new file mode 100644
index 0000000..a5467b0
--- /dev/null
+++ b/frontend/src/pages/admin/AdminFactionsPage.tsx
@@ -0,0 +1,59 @@
+import { useEffect, useState } from "react";
+
+import { ApiError } from "../../api/client";
+import { Spinner } from "../../components/Spinner";
+import { useToast } from "../../context/ToastContext";
+import { useAdminFactions, useAdminRenameFaction } from "../../hooks/admin";
+
+export function AdminFactionsPage() {
+ const { data: factions, isLoading } = useAdminFactions();
+ const rename = useAdminRenameFaction();
+ const toast = useToast();
+ const [names, setNames] = useState>({});
+
+ useEffect(() => {
+ if (factions) {
+ setNames(Object.fromEntries(factions.map((f) => [f.id, f.name_ru])));
+ }
+ }, [factions]);
+
+ if (isLoading) return ;
+
+ const save = async (id: number) => {
+ try {
+ await rename.mutateAsync({ factionId: id, name_ru: names[id] });
+ toast.show("Название обновлено во всей системе");
+ } catch (e) {
+ toast.show(e instanceof ApiError ? e.message : "Ошибка");
+ }
+ };
+
+ return (
+
+
Фракции
+
+ Переименование меняет название фракции во всех партиях и статистике.
+
+
+ {(factions ?? []).map((f) => (
+
+
{f.code}
+
+ setNames((n) => ({ ...n, [f.id]: e.target.value }))}
+ />
+ save(f.id)}
+ >
+ ОК
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx
new file mode 100644
index 0000000..d384a13
--- /dev/null
+++ b/frontend/src/pages/admin/AdminLayout.tsx
@@ -0,0 +1,44 @@
+import { NavLink, Outlet, useNavigate } from "react-router-dom";
+
+import { useAdminLogout } from "../../hooks/admin";
+
+export function AdminLayout() {
+ const logout = useAdminLogout();
+ const navigate = useNavigate();
+
+ const doLogout = async () => {
+ await logout.mutateAsync().catch(() => {});
+ navigate("/admin/login", { replace: true });
+ };
+
+ const tab = ({ isActive }: { isActive: boolean }) =>
+ "btn small" + (isActive ? " btn-primary" : " btn-ghost");
+
+ return (
+
+
+ Админ-панель
+
+ Выйти
+
+
+
+
+ Записи
+
+
+ Аккаунты
+
+
+ Фракции
+
+
+ Логи
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx
new file mode 100644
index 0000000..1c40820
--- /dev/null
+++ b/frontend/src/pages/admin/AdminLoginPage.tsx
@@ -0,0 +1,55 @@
+import { useState } from "react";
+import { Navigate, useNavigate } from "react-router-dom";
+
+import { ApiError } from "../../api/client";
+import { useAdminLogin, useAdminMe } from "../../hooks/admin";
+
+export function AdminLoginPage() {
+ const { data: admin } = useAdminMe();
+ const login = useAdminLogin();
+ const navigate = useNavigate();
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+
+ if (admin) return ;
+
+ const submit = async () => {
+ setError(null);
+ try {
+ await login.mutateAsync({ username, password });
+ navigate("/admin", { replace: true });
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "Ошибка входа");
+ }
+ };
+
+ return (
+
+
+
+ Администратор
+
+
+
+ Логин
+ setUsername(e.target.value)} />
+
+
+ Пароль
+ setPassword(e.target.value)} />
+
+ {error &&
{error}
}
+
+ Войти
+
+
+
+ navigate("/")}>
+ ← К приложению
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/admin/AdminLogsPage.tsx b/frontend/src/pages/admin/AdminLogsPage.tsx
new file mode 100644
index 0000000..3569f01
--- /dev/null
+++ b/frontend/src/pages/admin/AdminLogsPage.tsx
@@ -0,0 +1,67 @@
+import { useState } from "react";
+
+import { Spinner } from "../../components/Spinner";
+import { formatDateTime } from "../../domain/format";
+import { useAdminLogs } from "../../hooks/admin";
+
+const ACTIONS = ["", "login", "create", "update", "delete"];
+const ENTITIES = ["", "user", "group", "group_member", "match", "admin"];
+
+export function AdminLogsPage() {
+ const [action, setAction] = useState("");
+ const [entityType, setEntityType] = useState("");
+ const { data, isLoading } = useAdminLogs({
+ action: action || undefined,
+ entity_type: entityType || undefined,
+ });
+
+ return (
+
+
Журнал событий
+
+ setAction(e.target.value)}>
+ {ACTIONS.map((a) => (
+
+ {a || "все действия"}
+
+ ))}
+
+ setEntityType(e.target.value)}>
+ {ENTITIES.map((en) => (
+
+ {en || "все сущности"}
+
+ ))}
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {(data?.items ?? []).map((log) => (
+
+
+
+ {log.action} · {log.entity_type}
+ {log.entity_id != null ? ` #${log.entity_id}` : ""}
+
+ {formatDateTime(log.created_at)}
+
+
+ actor: {log.actor_id ?? "—"}
+ {log.ip ? ` · ${log.ip}` : ""}
+
+ {log.payload && (
+
+ {JSON.stringify(log.payload)}
+
+ )}
+
+ ))}
+ {(data?.items ?? []).length === 0 &&
Событий нет.
}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/admin/AdminMatchEdit.tsx b/frontend/src/pages/admin/AdminMatchEdit.tsx
new file mode 100644
index 0000000..61b07b9
--- /dev/null
+++ b/frontend/src/pages/admin/AdminMatchEdit.tsx
@@ -0,0 +1,170 @@
+import { useEffect, useState } from "react";
+
+import { ApiError } from "../../api/client";
+import { Spinner } from "../../components/Spinner";
+import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
+import { useToast } from "../../context/ToastContext";
+import { useAdminFactions, useAdminMatch, useAdminUpdateMatch } from "../../hooks/admin";
+
+interface Row {
+ user_id: number;
+ nickname: string;
+ faction_id: number;
+ place: number;
+ was_random: boolean;
+ comment: string;
+}
+
+export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
+ const { data: match, isLoading } = useAdminMatch(matchId);
+ const { data: factions } = useAdminFactions();
+ const update = useAdminUpdateMatch();
+ const toast = useToast();
+
+ const [rows, setRows] = useState([]);
+ const [playedAt, setPlayedAt] = useState("");
+ const [overall, setOverall] = useState("");
+ const [winReason, setWinReason] = useState("objectives");
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (match) {
+ setRows(
+ match.participants.map((p) => ({
+ user_id: p.user_id,
+ nickname: p.nickname,
+ faction_id: p.faction_id,
+ place: p.place ?? 1,
+ was_random: p.was_random,
+ comment: p.comment ?? "",
+ })),
+ );
+ setPlayedAt(match.played_at);
+ setOverall(match.overall_comment ?? "");
+ if (match.win_reason) setWinReason(match.win_reason);
+ }
+ }, [match]);
+
+ const upd = (i: number, patch: Partial) =>
+ setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
+
+ const save = async () => {
+ setError(null);
+ try {
+ await update.mutateAsync({
+ matchId,
+ body: {
+ played_at: playedAt,
+ overall_comment: overall.trim() || null,
+ win_reason: winReason,
+ participants: rows.map((r) => ({
+ user_id: r.user_id,
+ faction_id: r.faction_id,
+ place: r.place,
+ was_random: r.was_random,
+ comment: r.comment.trim() || null,
+ })),
+ },
+ });
+ toast.show("Партия обновлена");
+ onClose();
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
Правка партии #{matchId}
+
+ ✕
+
+
+
+ {isLoading || !match ? (
+
+ ) : (
+ <>
+
+ Дата
+ setPlayedAt(e.target.value)} />
+
+
+ {rows.map((r, i) => (
+
+
{r.nickname}
+
+
upd(i, { faction_id: Number(e.target.value) })}
+ >
+ {(factions ?? []).map((f) => (
+
+ {f.name_ru}
+
+ ))}
+
+
+ upd(i, { place: Math.max(1, Number(e.target.value) || 1) })}
+ />
+
+
+
upd(i, { comment: e.target.value })}
+ />
+
+ ))}
+
+
+ Причина победы
+ setWinReason(e.target.value as WinReason)}>
+ {WIN_REASONS.map((w) => (
+
+ {w.label}
+
+ ))}
+
+
+
+
+ Комментарий о партии
+
+
+ {error &&
{error}
}
+
+
+ Сохранить изменения
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/admin/AdminRecordsPage.tsx b/frontend/src/pages/admin/AdminRecordsPage.tsx
new file mode 100644
index 0000000..5364427
--- /dev/null
+++ b/frontend/src/pages/admin/AdminRecordsPage.tsx
@@ -0,0 +1,146 @@
+import { Pencil, Trash2 } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { Spinner } from "../../components/Spinner";
+import { useToast } from "../../context/ToastContext";
+import {
+ useAdminDeleteMatch,
+ useAdminFactions,
+ useAdminGroups,
+ useAdminMatches,
+ useAdminUsers,
+} from "../../hooks/admin";
+import type { AdminMatchRead } from "../../domain/types";
+import { AdminMatchEdit } from "./AdminMatchEdit";
+
+export function AdminRecordsPage() {
+ const [groupFilter, setGroupFilter] = useState(null);
+ const [userFilter, setUserFilter] = useState(null);
+ const [factionFilter, setFactionFilter] = useState(null);
+ const [grouped, setGrouped] = useState(true);
+ const [editId, setEditId] = useState(null);
+
+ const { data: groups } = useAdminGroups();
+ const { data: users } = useAdminUsers();
+ const { data: factions } = useAdminFactions();
+ const { data: matches, isLoading } = useAdminMatches({
+ group_id: groupFilter,
+ user_id: userFilter,
+ faction_id: factionFilter,
+ });
+ const del = useAdminDeleteMatch();
+ const toast = useToast();
+
+ const byGroup = useMemo(() => {
+ const map = new Map();
+ for (const m of matches ?? []) {
+ const g = map.get(m.group_id) ?? { name: m.group_name ?? `Группа ${m.group_id}`, items: [] };
+ g.items.push(m);
+ map.set(m.group_id, g);
+ }
+ return [...map.entries()];
+ }, [matches]);
+
+ const flat = useMemo(
+ () => [...(matches ?? [])].sort((a, b) => b.id - a.id),
+ [matches],
+ );
+
+ const card = (m: AdminMatchRead) => (
+
+
+
+
#{m.id} · {m.played_at}
+ {!grouped &&
· {m.group_name} }
+
+ {m.player_count} игр. · автор {m.created_by}
+
+
+
+
setEditId(m.id)}>
+
+
+
{
+ await del.mutateAsync(m.id).catch(() => {});
+ toast.show("Удалено");
+ }}
+ >
+
+
+
+
+
+ );
+
+ return (
+
+
+
+ setGroupFilter(e.target.value ? Number(e.target.value) : null)}
+ >
+ Все группы
+ {(groups ?? []).map((g) => (
+
+ {g.name}
+
+ ))}
+
+ setUserFilter(e.target.value ? Number(e.target.value) : null)}
+ >
+ Все игроки
+ {(users ?? []).filter((u) => u.role !== "admin").map((u) => (
+
+ {u.nickname}
+
+ ))}
+
+ setFactionFilter(e.target.value ? Number(e.target.value) : null)}
+ >
+ Все фракции
+ {(factions ?? []).map((f) => (
+
+ {f.name_ru}
+
+ ))}
+
+
+ setGrouped(e.target.checked)}
+ />
+ Группировать по группам
+
+
+
+
+ {isLoading ? (
+
+ ) : (matches ?? []).length === 0 ? (
+
Записей нет.
+ ) : grouped ? (
+ byGroup.map(([gid, { name, items }]) => (
+
+
+ {name} · {items.length}
+
+
{items.map(card)}
+
+ ))
+ ) : (
+
{flat.map(card)}
+ )}
+
+ {editId != null &&
setEditId(null)} />}
+
+ );
+}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
new file mode 100644
index 0000000..14b0589
--- /dev/null
+++ b/frontend/src/styles/global.css
@@ -0,0 +1,105 @@
+@import "./tokens.css";
+
+* { box-sizing: border-box; }
+
+html, body, #root {
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
+ Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ overscroll-behavior-y: none;
+}
+
+a { color: var(--accent-2); text-decoration: none; }
+
+h1, h2, h3 { margin: 0 0 8px; line-height: 1.2; }
+h1 { font-size: 22px; }
+h2 { font-size: 18px; }
+h3 { font-size: 15px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
+
+button {
+ font: inherit;
+ cursor: pointer;
+}
+
+input, select, textarea {
+ font: inherit;
+ width: 100%;
+ background: var(--surface-2);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 10px 12px;
+ outline: none;
+}
+input:focus, select:focus, textarea:focus { border-color: var(--accent); }
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 14px;
+ margin-bottom: 12px;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ background: var(--surface-2);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 10px 14px;
+ font-weight: 600;
+}
+.btn:active { transform: translateY(1px); }
+.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+.btn-ghost { background: transparent; }
+.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
+.btn[disabled] { opacity: 0.5; pointer-events: none; }
+
+.row { display: flex; align-items: center; gap: 10px; }
+.row-between { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.stack { display: flex; flex-direction: column; gap: 10px; }
+.muted { color: var(--text-muted); }
+.small { font-size: 13px; }
+.center { text-align: center; }
+
+.label { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; display: block; }
+.field { margin-bottom: 12px; }
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ font-size: 12px;
+}
+
+.error-text { color: var(--danger); font-size: 13px; }
+
+.toast {
+ position: fixed;
+ left: 50%;
+ bottom: calc(var(--bottombar-height) + 80px);
+ transform: translateX(-50%);
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 10px 16px;
+ border-radius: 999px;
+ z-index: 100;
+ max-width: 90%;
+ text-align: center;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
+}
diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css
new file mode 100644
index 0000000..c3f6487
--- /dev/null
+++ b/frontend/src/styles/layout.css
@@ -0,0 +1,128 @@
+.app-shell {
+ position: relative;
+ max-width: var(--app-max-w);
+ margin: 0 auto;
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg);
+ box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
+}
+
+.top-bar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: calc(var(--safe-top) + 12px) 16px 12px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+}
+.top-bar .title { font-weight: 700; font-size: 17px; }
+
+.app-main {
+ flex: 1;
+ overflow-y: auto;
+ padding: 14px;
+ padding-bottom: calc(var(--bottombar-height) + var(--fab-height) + var(--safe-bottom) + 28px);
+}
+
+.bottom-bar {
+ position: fixed;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 100%;
+ max-width: var(--app-max-w);
+ height: calc(var(--bottombar-height) + var(--safe-bottom));
+ padding-bottom: var(--safe-bottom);
+ background: var(--surface);
+ border-top: 1px solid var(--border);
+ display: flex;
+ align-items: stretch;
+ z-index: 20;
+}
+
+.bottom-bar .slot {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ color: var(--text-muted);
+ background: none;
+ border: none;
+ font-size: 12px;
+}
+.bottom-bar .slot .name { color: var(--text); font-weight: 600; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.bottom-bar .spacer { width: var(--fab-width); flex: 0 0 var(--fab-width); }
+
+.fab {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ bottom: calc(var(--bottombar-height) + var(--safe-bottom) - 2px);
+ width: var(--fab-width);
+ height: var(--fab-height);
+ border: 1px solid var(--accent);
+ border-bottom: none;
+ border-top-left-radius: var(--fab-width);
+ border-top-right-radius: var(--fab-width);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ display: grid;
+ place-items: center;
+ background: var(--accent);
+ color: #fff;
+ box-shadow: 0 -4px 14px rgba(224, 83, 61, 0.45);
+}
+.fab svg { margin-top: 6px; }
+.fab[aria-disabled="true"] {
+ background: var(--surface-2);
+ border-color: var(--border);
+ color: var(--text-muted);
+ box-shadow: none;
+}
+
+/* Таблицы / списки лидерборда */
+.lb-row {
+ display: grid;
+ grid-template-columns: 28px 1fr auto;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 4px;
+ border-bottom: 1px solid var(--border);
+}
+.lb-row:last-child { border-bottom: none; }
+.lb-rank { font-weight: 700; color: var(--text-muted); text-align: center; }
+.lb-rank.top { color: var(--gold); }
+.lb-metrics { display: flex; gap: 12px; font-size: 13px; color: var(--text-muted); }
+.lb-score { font-weight: 700; color: var(--accent-2); }
+
+.participant-row {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ margin-bottom: 10px;
+ background: var(--surface-2);
+}
+.place-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 24px;
+ height: 24px;
+ padding: 0 6px;
+ border-radius: 6px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ font-weight: 700;
+ font-size: 13px;
+}
+.place-badge.first { background: var(--gold); color: #1a1a1a; border-color: var(--gold); }
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
new file mode 100644
index 0000000..5aedade
--- /dev/null
+++ b/frontend/src/styles/tokens.css
@@ -0,0 +1,27 @@
+:root {
+ /* Палитра (тёмная тема) */
+ --bg: #0f1115;
+ --surface: #171a21;
+ --surface-2: #1f242e;
+ --border: #2a2f3a;
+ --text: #e8eaed;
+ --text-muted: #9aa0aa;
+ --accent: #e0533d;
+ --accent-2: #f0a04b;
+ --success: #4caf7d;
+ --danger: #e0533d;
+ --gold: #f0c14b;
+
+ /* Размеры */
+ --app-max-w: 480px;
+ --bottombar-height: 64px;
+ --fab-width: 88px;
+ --fab-height: 44px;
+ --radius: 14px;
+ --radius-sm: 10px;
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-top: env(safe-area-inset-top, 0px);
+
+ /* Брейкпоинты (для справки) */
+ --bp-lg: 768px;
+}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..962d6dc
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..c983dd7
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "noEmit": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..963ca04
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// Dev: SPA на :5173 проксирует /api на бэкенд :8000 — пути одинаковы в dev и prod.
+// Prod: dist отдаётся самим FastAPI (single-origin), прокси не нужен.
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ // Слушаем на всех интерфейсах (IPv4 + IPv6), чтобы и localhost, и 127.0.0.1
+ // работали без таймаута IPv4→IPv6 на Windows. Внимание: dev-сервер при этом
+ // доступен и из локальной сети (для разработки на своей машине это нормально).
+ host: true,
+ port: 5173,
+ proxy: {
+ "/api": {
+ // Явный IPv4: на Windows "localhost" может резолвиться в IPv6 ::1,
+ // куда uvicorn не слушает → задержка ~1 с на установку соединения.
+ target: "http://127.0.0.1:8000",
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: "dist",
+ sourcemap: false,
+ },
+});
diff --git a/run.ps1 b/run.ps1
new file mode 100644
index 0000000..3a35703
--- /dev/null
+++ b/run.ps1
@@ -0,0 +1,73 @@
+#!/usr/bin/env pwsh
+# ╔═══════════════════════════════════════════════════════════════════════════╗
+# ║ Единый лаунчер dev / test. Что запускать — решает APP_ENV из корневого .env ║
+# ╚═══════════════════════════════════════════════════════════════════════════╝
+# development → uvicorn --reload (бэк) + vite (фронт), нативно, в двух окнах
+# test → docker compose прод-клон (порт 8080)
+# production → лаунчером НЕ запускается (прод обособлен: docker compose up -d на Pi)
+#
+# Запуск: .\run.ps1
+# Если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
+$ErrorActionPreference = "Stop"
+$root = $PSScriptRoot
+
+$envFile = Join-Path $root ".env"
+if (-not (Test-Path $envFile)) {
+ Write-Host "Нет .env в корне. Скопируйте: Copy-Item .env.example .env" -ForegroundColor Red
+ exit 1
+}
+
+# Читаем APP_ENV из .env (последнее присвоение побеждает).
+$appEnv = "development"
+foreach ($line in Get-Content $envFile) {
+ if ($line -match '^\s*APP_ENV\s*=\s*([^#\s]+)') { $appEnv = $matches[1].Trim().ToLower() }
+}
+Write-Host "APP_ENV = $appEnv" -ForegroundColor Cyan
+
+switch ($appEnv) {
+ "development" {
+ $backend = Join-Path $root "backend"
+ $frontend = Join-Path $root "frontend"
+ $py = Join-Path $backend ".venv\Scripts\python.exe"
+ if (-not (Test-Path $py)) {
+ Write-Host "Нет venv: $py" -ForegroundColor Red
+ Write-Host "Сначала: cd backend; python -m venv .venv; .\.venv\Scripts\Activate.ps1; pip install -e `".[dev]`"" -ForegroundColor Yellow
+ exit 1
+ }
+ if (-not (Test-Path (Join-Path $frontend "node_modules"))) {
+ Write-Host "Нет node_modules. Сначала: cd frontend; npm install" -ForegroundColor Red
+ exit 1
+ }
+ Write-Host "Запуск бэка (uvicorn --reload) и фронта (vite) в отдельных окнах…" -ForegroundColor Green
+ Start-Process powershell -ArgumentList @(
+ "-NoExit", "-Command",
+ "Set-Location '$backend'; & '$py' -m uvicorn app.main:app --reload"
+ )
+ Start-Process powershell -ArgumentList @(
+ "-NoExit", "-Command",
+ "Set-Location '$frontend'; npm run dev"
+ )
+ Write-Host ""
+ Write-Host " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)" -ForegroundColor Green
+ Write-Host " Фронт: http://127.0.0.1:5173" -ForegroundColor Green
+ }
+ "test" {
+ $compose = Join-Path $root "docker-compose.test.yml"
+ Write-Host "Сборка и запуск прод-клона в Docker (порт 8080)…" -ForegroundColor Green
+ docker compose -f $compose up --build -d
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ Write-Host ""
+ Write-Host " Открыть: http://localhost:8080 (Swagger: /api/docs)" -ForegroundColor Green
+ Write-Host " Логи: docker compose -f docker-compose.test.yml logs -f"
+ Write-Host " Стоп: docker compose -f docker-compose.test.yml down -v"
+ }
+ "production" {
+ Write-Host "production лаунчером не запускается — прод обособлен." -ForegroundColor Yellow
+ Write-Host "Деплой на Pi (из ветки main): docker compose up -d --build"
+ exit 1
+ }
+ default {
+ Write-Host "Неизвестный APP_ENV='$appEnv'. Допустимо: development | test | production." -ForegroundColor Red
+ exit 1
+ }
+}
diff --git a/run.sh b/run.sh
new file mode 100644
index 0000000..88ac473
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# ╔═══════════════════════════════════════════════════════════════════════════╗
+# ║ Единый лаунчер dev / test. Что запускать — решает APP_ENV из корневого .env ║
+# ╚═══════════════════════════════════════════════════════════════════════════╝
+# development → uvicorn --reload (бэк) + vite (фронт), нативно (Ctrl+C останавливает оба)
+# test → docker compose прод-клон (порт 8080)
+# production → лаунчером НЕ запускается (прод обособлен: docker compose up -d на Pi)
+#
+# Запуск: ./run.sh (при необходимости: chmod +x run.sh)
+set -euo pipefail
+root="$(cd "$(dirname "$0")" && pwd)"
+
+envf="$root/.env"
+[ -f "$envf" ] || { echo "Нет .env в корне. Скопируйте: cp .env.example .env"; exit 1; }
+
+# APP_ENV из .env (последнее присвоение, без комментариев/пробелов).
+app_env="$(grep -E '^[[:space:]]*APP_ENV[[:space:]]*=' "$envf" | tail -n1 \
+ | sed -E 's/^[^=]*=[[:space:]]*//; s/[[:space:]#].*$//' | tr 'A-Z' 'a-z')"
+app_env="${app_env:-development}"
+echo "APP_ENV = $app_env"
+
+case "$app_env" in
+ development)
+ py="$root/backend/.venv/bin/python"
+ [ -x "$py" ] || py="$root/backend/.venv/Scripts/python.exe" # Git Bash на Windows
+ [ -x "$py" ] || { echo "Нет venv. Сначала: cd backend && python -m venv .venv && pip install -e '.[dev]'"; exit 1; }
+ [ -d "$root/frontend/node_modules" ] || { echo "Нет node_modules. Сначала: cd frontend && npm install"; exit 1; }
+ echo "Запуск бэка (uvicorn --reload) и фронта (vite)… Ctrl+C — остановить оба."
+ ( cd "$root/backend" && exec "$py" -m uvicorn app.main:app --reload ) &
+ back=$!
+ trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
+ echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
+ echo " Фронт: http://127.0.0.1:5173"
+ ( cd "$root/frontend" && npm run dev )
+ ;;
+ test)
+ echo "Сборка и запуск прод-клона в Docker (порт 8080)…"
+ docker compose -f "$root/docker-compose.test.yml" up --build -d
+ echo " Открыть: http://localhost:8080 (Swagger: /api/docs)"
+ echo " Логи: docker compose -f docker-compose.test.yml logs -f"
+ echo " Стоп: docker compose -f docker-compose.test.yml down -v"
+ ;;
+ production)
+ echo "production лаунчером не запускается — прод обособлен."
+ echo "Деплой на Pi (из ветки main): docker compose up -d --build"
+ exit 1
+ ;;
+ *)
+ echo "Неизвестный APP_ENV='$app_env'. Допустимо: development | test | production."
+ exit 1
+ ;;
+esac
diff --git a/scripts/backup.sh b/scripts/backup.sh
new file mode 100644
index 0000000..69e1fc9
--- /dev/null
+++ b/scripts/backup.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Консистентный онлайн-бэкап SQLite из работающего контейнера.
+# Запуск по cron на хосте Pi, например ежедневно в 04:00:
+# 0 4 * * * /home/pi/ForbidenStarsApp/scripts/backup.sh >> /var/log/fs-backup.log 2>&1
+set -euo pipefail
+
+PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}"
+KEEP="${KEEP:-14}"
+SERVICE="app"
+TS="$(date +%Y%m%d_%H%M%S)"
+
+mkdir -p "$BACKUP_DIR"
+
+# .backup внутри контейнера (sqlite3 модуль Python) → консистентная копия в /data.
+docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T "$SERVICE" \
+ python -c "import sqlite3; s=sqlite3.connect('/data/forbidden_stars.db'); d=sqlite3.connect('/data/backup_tmp.db'); s.backup(d); d.close(); s.close()"
+
+# Копируем во вне контейнера и сжимаем.
+CID="$(docker compose -f "$PROJECT_DIR/docker-compose.yml" ps -q "$SERVICE")"
+docker cp "$CID:/data/backup_tmp.db" "$BACKUP_DIR/fs_$TS.db"
+docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T "$SERVICE" rm -f /data/backup_tmp.db
+gzip -f "$BACKUP_DIR/fs_$TS.db"
+
+# Ротация: оставить последние $KEEP.
+ls -1t "$BACKUP_DIR"/fs_*.db.gz 2>/dev/null | tail -n +"$((KEEP + 1))" | xargs -r rm -f
+
+echo "[backup] Готово: $BACKUP_DIR/fs_$TS.db.gz"
diff --git a/scripts/export-prod.sh b/scripts/export-prod.sh
new file mode 100644
index 0000000..1e71a9b
--- /dev/null
+++ b/scripts/export-prod.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# Выгрузка ПРОДА: в целевую папку попадают только файлы, нужные для запуска
+# прод-контейнера (без тестов, dev-входа и тест-специфики).
+#
+# Использование: scripts/export-prod.sh <целевая-папка> [git-ref]
+# git-ref по умолчанию HEAD; для прод-ветки: scripts/export-prod.sh /srv/fs prod
+set -euo pipefail
+
+DEST="${1:?Укажите целевую папку: scripts/export-prod.sh [ref]}"
+REF="${2:-HEAD}"
+
+mkdir -p "$DEST"
+# git archive уважает export-ignore из .gitattributes (тесты, dev-вход и т.п. отсеяны)
+git archive --format=tar "$REF" | tar -x -C "$DEST"
+
+# тест/dev-специфика в проде не нужна (тест-compose и лаунчер)
+rm -f "$DEST/docker-compose.test.yml" "$DEST/run.ps1" "$DEST/run.sh"
+
+echo "[export-prod] Прод выгружен в: $DEST"
+echo " дальше: cp .env.example .env && docker compose up -d --build"
diff --git a/scripts/export-test.sh b/scripts/export-test.sh
new file mode 100644
index 0000000..71a90c0
--- /dev/null
+++ b/scripts/export-test.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Выгрузка ТЕСТА (прод-клон для локального контейнера на x86): в целевую папку
+# попадают только файлы, нужные для запуска тест-контейнера.
+#
+# Использование: scripts/export-test.sh <целевая-папка> [git-ref]
+set -euo pipefail
+
+DEST="${1:?Укажите целевую папку: scripts/export-test.sh [ref]}"
+REF="${2:-HEAD}"
+
+mkdir -p "$DEST"
+git archive --format=tar "$REF" | tar -x -C "$DEST"
+
+# прод-compose в тест-папке не нужен (тест запускается своим docker-compose.test.yml)
+rm -f "$DEST/docker-compose.yml"
+
+echo "[export-test] Тест выгружен в: $DEST"
+echo " дальше: cp .env.example .env (APP_ENV=test) && docker compose -f docker-compose.test.yml up -d --build"