# Backend API — контракт для frontend ## Base URL и авторизация SPA обращается к относительному префиксу `/api`. В dev Vite проксирует его на `http://localhost:3001/api`; в Docker nginx проксирует на `runners-calendar-backend:3000`. Все маршруты `/api/races` требуют сессионную cookie авторизованного пользователя с подтверждённым email. Записи принадлежат текущему пользователю: чужая запись не видна и отвечает `404`. После `POST /api/auth/login` или `GET /api/auth/me` клиент получает `csrfToken`. Для каждого `POST`, `PATCH` и `DELETE` с активной сессией его нужно передавать в заголовке `X-CSRF-Token`. Cookie передаётся браузером автоматически; при отдельном origin запрос должен использовать credentials. ## Аккаунт и сессии Все маршруты этого раздела требуют авторизации и подтверждённого email. Для изменяющих состояние запросов обязателен `X-CSRF-Token`. ### `GET /api/auth/sessions` Возвращает активные сессии текущего пользователя. Хеши и сами токены не возвращаются. ```json { "sessions": [{ "id": "UUID", "createdAt": "ISO datetime", "lastSeenAt": "ISO datetime", "expiresAt": "ISO datetime", "current": true }] } ``` ### `POST /api/auth/password` ```json { "currentPassword": "…", "newPassword": "не менее 15 символов" } ``` Проверяет текущий пароль, сохраняет новый и отзывает все остальные активные сессии. Успех: `200 { "ok": true }`; неверный текущий пароль: `400` `invalid_current_password`. ### `DELETE /api/auth/sessions/:id` Отзывает указанную активную сессию текущего пользователя. Успех: `204`. Если отозвана текущая сессия, cookie очищается. Чужая, неактивная или отсутствующая сессия отвечает `404`. ### `POST /api/auth/sessions/revoke-others` Отзывает все активные сессии, кроме текущей. Успех: `200 { "ok": true }`. ## Служебные маршруты ### `GET /api/health` Liveness без проверки БД: ```json { "status": "ok", "version": "" } ``` ### `GET /api/meta` Версия для footer: ```json { "version": "" } ``` ### `GET /api/ready` Проверяет БД. Успех: `{ "status": "ready", "db": "connected" }`. Недоступная БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. ## Забеги ### `GET /api/races` Возвращает только забеги текущего пользователя, отсортированные по дате. Опциональные query-параметры: `year` (целое число), `month` (1–12), `status` (`planned`, `registered` или `completed`), `dateFrom`/`dateTo` (`YYYY-MM-DD`) и `distanceMin`/`distanceMax` (километры, от `0.001` до `999.999`). Диапазоны не могут быть перевёрнуты; без результатов возвращается `[]`. ### `GET /api/races/:id` Возвращает один забег. `id` — UUID, который создаёт сервер. Не путайте его с `slug`: `slug` создаёт клиент как читаемый ключ и он уникален в рамках одного пользователя. ### `POST /api/races` Создаёт забег. Обязательные поля: `slug`, `date`, `title`, `distanceKm`. Неизвестные поля отклоняются. ```json { "slug": "2026-06-01-my-race", "date": "2026-06-01", "title": "Мой забег", "distanceKm": 10, "status": "planned", "officialUrl": "https://example.com", "startTime": "09:30" } ``` Успех: `201` и объект `Race`. Одинаковый `slug` у того же пользователя: `409` `{ "error": "conflict", "details": ["Race with this slug already exists"] }`. Если `coverImageUrl` не передан, backend может найти Open Graph-обложку по `officialUrl`; это best-effort и не мешает созданию при ошибке внешнего сайта. ### `PATCH /api/races/:id` Частично обновляет забег. Передайте хотя бы одно поле из модели ниже; неизвестные поля и пустой объект отклоняются. Успех: `200` и обновлённый `Race`. ### `DELETE /api/races/:id` Удаляет забег. Успех: `204` без тела. При изменении `officialUrl` backend best-effort получает обложку, если у старта ещё нет обложки. Ручная `coverImageUrl` не перезаписывается. Одноразовое восстановление обложек существующих стартов запускается командой `npm run backfill:covers`. Команда идемпотентна и печатает только агрегированную статистику. ## Модель `Race` | Поле | Тип | POST | PATCH | Ограничение | |---|---|---:|---:|---| | `id` | UUID | сервер | — | Read-only UUID сервера | | `slug` | string | да | да | 1–120 символов: буквы/цифры, разделённые `-` | | `date` | string | да | да | Реальная дата `YYYY-MM-DD` | | `title` | string | да | да | 1–200 символов | | `distanceKm` | number | да | да | Больше 0, не более 999.999 | | `status` | string/null | нет | да | `planned`, `registered` или `completed` | | `officialUrl` | HTTP(S) URL/null | нет | да | До 2048 символов | | `coverImageUrl` | HTTP(S) URL/null | нет | да | До 2048 символов | | `startTime` | string/null | нет | да | `HH:MM` или `HH:MM:SS` | | `clusterSchedule` | string/null | нет | да | До 1000 символов | | `bibPickup` | string/null | нет | да | До 500 символов | | `bibNumber` | string/null | нет | да | До 100 символов | | `finishTime` | string/null | нет | да | `SS`, `MM:SS` или `H:MM:SS` | | `finishPlace` | string/null | нет | да | До 100 символов | | `notes` | string/null | нет | да | До 5000 символов | | `createdAt` | ISO datetime | сервер | — | Read-only | | `updatedAt` | ISO datetime/null | сервер | — | Read-only | ## Ошибки | Статус | Тело | Когда | |---:|---|---| | 400 | `{ "error": "validation_error", "details": ["…"] }` | Неверный UUID, query или тело запроса | | 401 | `{ "error": "unauthorized", "details": ["Authentication required"] }` | Нет сессии | | 403 | `csrf_error` / `email_not_verified` | Неверный CSRF-токен или email не подтверждён | | 404 | `{ "error": "not_found", "details": ["Race not found"] }` | Забега нет либо он принадлежит другому пользователю | | 409 | `conflict` | Дублирующий `slug` пользователя | | 500 | `unknown_error` | Непредвиденная ошибка приложения | | 503 | `database_unavailable` | База недоступна | Некорректный JSON также отвечает `400 validation_error`. ## Seed `npm run seed` выполняет upsert по `slug` и не перезаписывает пользовательские изменения. После включения авторизации ему нужен `SEED_OWNER_USER_ID` или `SEED_OWNER_EMAIL`.