# 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"` или `"09:30:00"` (часы:минуты:секунды) |
| `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" }`.
- В логах сервера: строка ошибки с контекстом маршрута.
