# Backend API — шпаргалка для фронтенда ## 1. Base URL SPA всегда отправляет запросы на относительный префикс `/api` текущего origin. - В dev (`npm run dev`): Vite proxy отправляет `/api/*` на `http://localhost:3001/api/*`. - В Docker/проде: nginx фронта проксирует `/api/*` на хост `runners-calendar-backend:3000` в той же сети (уникальное имя сервиса Compose, без коллизий с чужими стеками). ## 2. CORS В dev-режиме бэкенд ожидает переменную: ``` CORS_ORIGIN=http://localhost:5173 ``` Разрешены методы `GET`, `POST`, `PATCH`, `DELETE` и заголовок `Content-Type: application/json`. ## 3. Эндпоинты ### `GET /api/health` Liveness-проверка (без обращения к БД). **Ответ 200:** ```json { "status": "ok" } ``` --- ### `GET /api/ready` Readiness-проверка (проверяет подключение к БД). **Ответ 200:** ```json { "status": "ready", "db": "connected" } ``` **Ответ 503:** ```json { "error": "database_unavailable", "db": "disconnected" } ``` --- ### `GET /api/races` Список забегов, отсортированных по дате. **Query-параметры (опциональные):** | Параметр | Тип | Описание | |---|---|---| | `year` | number | Фильтр по году (напр. `2026`) | | `month` | number | Фильтр по месяцу (1–12) | - Без параметров — возвращает все забеги. - Можно указать только `year`, только `month` или оба. - `month` без `year` фильтрует по месяцу **всех** лет. **Пример запроса:** ``` GET /api/races?year=2026&month=5 ``` **Ответ 200:** ```json [ { "id": "2026-05-03-kazanskii-marafon", "date": "2026-05-03", "title": "Казанский марафон", "distanceKm": 42.195, "status": "planned", "officialUrl": null, "startTime": null, "clusterSchedule": null, "bibPickup": null, "bibNumber": null, "finishTime": null, "finishPlace": null, "notes": null, "createdAt": "2026-03-31T12:00:00.000Z", "updatedAt": null } ] ``` --- ### `GET /api/races/:id` Одна запись по `id`. **Ответ 200:** объект `Race` (см. модель ниже). **Ответ 404:** тело JSON, поле `details` — массив пояснений (можно показывать в UI или игнорировать). ```json { "error": "not_found", "details": ["Race not found"] } ``` --- ### `POST /api/races` Создание забега. **Тело запроса (JSON):** ```json { "id": "2026-06-01-my-race", "date": "2026-06-01", "title": "Мой забег", "distanceKm": 10, "status": "planned", "officialUrl": "https://example.com", "startTime": "09:30", "clusterSchedule": null, "bibPickup": null, "bibNumber": null, "finishTime": null, "finishPlace": null, "notes": null } ``` **Обязательные поля:** `id`, `date`, `title`, `distanceKm`. **Ответ 201:** созданный объект `Race`. **Ответ 400:** ```json { "error": "validation_error", "details": ["Fields id, date, title, distanceKm are required"] } ``` **Ответ 409:** ```json { "error": "conflict", "details": ["Race with this id already exists"] } ``` --- ### `PATCH /api/races/:id` Частичное обновление — передавать **только** изменяемые поля. **Тело запроса (JSON):** ```json { "finishTime": "1:45:30", "finishPlace": "12/340", "bibNumber": "1234", "status": "completed" } ``` **Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`. **Ответ 200:** обновлённый объект `Race`. **Ответ 400:** ```json { "error": "validation_error", "details": ["No updatable fields provided"] } ``` **Ответ 404:** ```json { "error": "not_found", "details": ["Race not found"] } ``` --- ### `DELETE /api/races/:id` Удаление забега. **Ответ 204:** пустое тело. **Ответ 404:** ```json { "error": "not_found", "details": ["Race not found"] } ``` ## 4. Модель `Race` (camelCase) | Поле | Тип | POST обяз. | PATCH | Описание | |---|---|---|---|---| | `id` | string | да | — | Стабильный ключ, напр. `2026-05-03-kazan-marathon` | | `date` | string | да | да | `YYYY-MM-DD` | | `title` | string | да | да | Название забега | | `distanceKm` | number | да | да | Дистанция в км | | `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` | | `officialUrl` | string \| null | нет | да | URL организатора | | `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` | | `clusterSchedule` | string \| null | нет | да | Расписание кластеров | | `bibPickup` | string \| null | нет | да | Выдача номеров | | `bibNumber` | string \| null | нет | да | Стартовый номер | | `finishTime` | string \| null | нет | да | Финишное время `H:MM:SS` или `MM:SS` | | `finishPlace` | string \| null | нет | да | Место на финише (произвольная строка) | | `notes` | string \| null | нет | да | Заметки | | `createdAt` | string | — | — | ISO timestamp (read-only) | | `updatedAt` | string \| null | — | — | ISO timestamp (read-only) | ## 5. Фильтрация списка (`GET /api/races`) - **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`. - **`month`** — целое число 1–12, фильтрует по `EXTRACT(MONTH FROM race_date)`. - Параметры можно комбинировать (`?year=2026&month=5`) или указывать по одному. - Без параметров — все забеги без ограничения (пагинация пока не реализована). - Если по фильтру нет результатов — пустой массив `[]`, статус `200`. ## 6. Идемпотентность seed Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert** по полю `id` (`INSERT … ON CONFLICT (id) DO UPDATE`). Источник данных — `import/races_2026_calendar.csv` из корня репозитория. Повторный запуск безопасен. ## 7. Поведение при недоступной БД - `GET /api/health` — всегда `200` (не проверяет БД). - `GET /api/ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`. - Все остальные маршруты — `503 { "error": "database_unavailable" }`. - В логах сервера: строка ошибки с контекстом маршрута.