Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- Remove PLAN/agent instruction files; single root .env.example for DB + API - Stack compose uses env_file .env; delete stack env example duplicate - Refresh README, backend docs, API doc; trim gitignore/dockerignore Made-with: Cursor
241 lines
7.2 KiB
Markdown
241 lines
7.2 KiB
Markdown
# Backend API — шпаргалка для фронтенда
|
||
|
||
## 1. Base URL
|
||
|
||
```
|
||
VITE_API_BASE_URL=http://localhost:3001
|
||
```
|
||
|
||
В коде SPA: `import.meta.env.VITE_API_BASE_URL`.
|
||
|
||
В Docker-стеке из репозитория образ фронта собирается с **`VITE_API_BASE_URL=/api`**: запросы идут на тот же origin, nginx проксирует `/api` на backend (см. `docker/nginx.frontend.conf`).
|
||
|
||
## 2. CORS
|
||
|
||
В dev-режиме бэкенд ожидает переменную:
|
||
|
||
```
|
||
CORS_ORIGIN=http://localhost:5173
|
||
```
|
||
|
||
Разрешены методы `GET`, `POST`, `PATCH`, `DELETE` и заголовок `Content-Type: application/json`.
|
||
|
||
## 3. Эндпоинты
|
||
|
||
### `GET /health`
|
||
|
||
Liveness-проверка (без обращения к БД).
|
||
|
||
**Ответ 200:**
|
||
|
||
```json
|
||
{ "status": "ok" }
|
||
```
|
||
|
||
---
|
||
|
||
### `GET /ready`
|
||
|
||
Readiness-проверка (проверяет подключение к БД).
|
||
|
||
**Ответ 200:**
|
||
|
||
```json
|
||
{ "status": "ready", "db": "connected" }
|
||
```
|
||
|
||
**Ответ 503:**
|
||
|
||
```json
|
||
{ "error": "database_unavailable", "db": "disconnected" }
|
||
```
|
||
|
||
---
|
||
|
||
### `GET /races`
|
||
|
||
Список забегов, отсортированных по дате.
|
||
|
||
**Query-параметры (опциональные):**
|
||
|
||
| Параметр | Тип | Описание |
|
||
|---|---|---|
|
||
| `year` | number | Фильтр по году (напр. `2026`) |
|
||
| `month` | number | Фильтр по месяцу (1–12) |
|
||
|
||
- Без параметров — возвращает все забеги.
|
||
- Можно указать только `year`, только `month` или оба.
|
||
- `month` без `year` фильтрует по месяцу **всех** лет.
|
||
|
||
**Пример запроса:**
|
||
|
||
```
|
||
GET /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 /races/:id`
|
||
|
||
Одна запись по `id`.
|
||
|
||
**Ответ 200:** объект `Race` (см. модель ниже).
|
||
|
||
**Ответ 404:** тело JSON, поле `details` — массив пояснений (можно показывать в UI или игнорировать).
|
||
|
||
```json
|
||
{ "error": "not_found", "details": ["Race not found"] }
|
||
```
|
||
|
||
---
|
||
|
||
### `POST /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 /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 /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 /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 /health` — всегда `200` (не проверяет БД).
|
||
- `GET /ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`.
|
||
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
|
||
- В логах сервера: строка ошибки с контекстом маршрута.
|