feat(backend): implement REST API for races calendar

Express + TypeScript backend with PostgreSQL: CRUD endpoints for /races (GET list with year/month filters, GET by id, POST, PATCH, DELETE), health/readiness probes, SQL migration runner, seed script with upsert from CSV, camelCase/snake_case mapper, CORS, env validation, docker-compose, and API docs for frontend.

Made-with: Cursor
This commit is contained in:
Anton
2026-04-01 14:47:53 +03:00
parent 88a448dd8e
commit 698ae37553
17 changed files with 2242 additions and 0 deletions

View File

@@ -0,0 +1,234 @@
# Backend API — шпаргалка для фронтенда
## 1. Base URL
```
VITE_API_BASE_URL=http://localhost:3001
```
В коде SPA: `import.meta.env.VITE_API_BASE_URL`.
## 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 | Фильтр по месяцу (112) |
- Без параметров — возвращает все забеги.
- Можно указать только `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,
"notes": null,
"createdAt": "2026-03-31T12:00:00.000Z",
"updatedAt": null
}
]
```
---
### `GET /races/:id`
Одна запись по `id`.
**Ответ 200:** объект `Race` (см. модель ниже).
**Ответ 404:**
```json
{ "error": "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,
"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",
"bibNumber": "1234",
"status": "completed"
}
```
**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `notes`.
**Ответ 200:** обновлённый объект `Race`.
**Ответ 400:**
```json
{ "error": "validation_error", "details": ["No updatable fields provided"] }
```
**Ответ 404:**
```json
{ "error": "not_found" }
```
---
### `DELETE /races/:id`
Удаление забега.
**Ответ 204:** пустое тело.
**Ответ 404:**
```json
{ "error": "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"` / `"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` |
| `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`** — целое число 112, фильтрует по `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" }`.
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
- В логах сервера: строка ошибки с контекстом маршрута.

107
docs/backend.md Normal file
View File

@@ -0,0 +1,107 @@
# Backend — эксплуатация
## Стек
- **Node.js LTS** + TypeScript
- **Express** (HTTP-фреймворк)
- **pg** (PostgreSQL клиент)
- **csv-parse** (парсинг CSV для seed)
## Быстрый старт
### 1. Поднять PostgreSQL
```bash
# из корня проекта
docker-compose up -d
```
Параметры подключения берутся из `.env` (см. `.env.example` в корне).
### 2. Установить зависимости
```bash
cd backend
npm install
```
### 3. Создать `.env`
Скопировать `.env.example` из корня проекта и при необходимости отредактировать:
```bash
cp ../.env.example ../.env
```
### 4. Миграции
```bash
npm run db:migrate
```
Миграционный раннер — собственный скрипт `src/migrate.ts`:
- хранит историю применённых файлов в таблице `_migrations`;
- идемпотентный — повторный запуск не применяет уже выполненные миграции;
- файлы миграций: `backend/migrations/*.sql`, применяются в алфавитном порядке.
### 5. Seed (начальный набор данных)
```bash
npm run seed
```
- Читает `import/races_2026_calendar.csv` из корня репо.
- Генерирует стабильный `id` в формате `{date}-{slug}`.
- Выполняет **upsert** (`INSERT … ON CONFLICT DO UPDATE`) — безопасно для повторного запуска.
### 6. Запуск API
```bash
npm run dev # dev-режим через ts-node
npm run build # компиляция в dist/
npm start # запуск из dist/
```
API слушает порт из `API_PORT` (по умолчанию `3001`).
## Переменные окружения
| Переменная | Описание | По умолчанию |
|---|---|---|
| `DB_HOST` | Хост PostgreSQL | — (обязательна) |
| `DB_PORT` | Порт PostgreSQL | — (обязательна) |
| `DB_NAME` | Имя базы данных | — (обязательна) |
| `DB_USER` | Пользователь БД | — (обязательна) |
| `DB_PASSWORD` | Пароль БД | — (обязательна) |
| `API_PORT` | Порт API-сервера | `3001` |
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
При отсутствии любой из `DB_*` процесс падает при старте с сообщением `Missing required environment variable: <NAME>`.
## Поведение при недоступной БД
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
- **`GET /health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
- **`GET /ready`** — пробует подключиться к БД; возвращает `200` если ОК, `503 { "error": "database_unavailable" }` если нет.
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
## Структура каталога
```
backend/
├── migrations/
│ └── 001_create_races.sql
├── src/
│ ├── config.ts # загрузка и валидация env
│ ├── db.ts # pg Pool
│ ├── index.ts # точка входа Express
│ ├── migrate.ts # раннер миграций
│ ├── seed.ts # разовый импорт CSV
│ ├── mappers/
│ │ └── race.ts # snake_case ↔ camelCase
│ └── routes/
│ ├── health.ts # /health, /ready
│ └── races.ts # CRUD /races
├── package.json
└── tsconfig.json
```