forked from admin/runners-calendar
177 lines
8.1 KiB
Markdown
177 lines
8.1 KiB
Markdown
# 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": "<backend version>" }
|
||
```
|
||
|
||
### `GET /api/meta`
|
||
|
||
Версия для footer:
|
||
|
||
```json
|
||
{ "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`.
|
||
Неизвестные поля отклоняются.
|
||
|
||
```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`.
|