8.1 KiB
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
Возвращает активные сессии текущего пользователя. Хеши и сами токены не возвращаются.
{
"sessions": [{
"id": "UUID",
"createdAt": "ISO datetime",
"lastSeenAt": "ISO datetime",
"expiresAt": "ISO datetime",
"current": true
}]
}
POST /api/auth/password
{ "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 без проверки БД:
{ "status": "ok", "version": "<backend version>" }
GET /api/meta
Версия для footer:
{ "version": "<backend 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.
Неизвестные поля отклоняются.
{
"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.