diff --git a/.env.example b/.env.example index aab05f8..e789991 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ # ─── PostgreSQL ─────────────────────────────────────────────── +# Не обязательны, если CALENDAR_RUN_MOCK_DB=1 (только HTTP API без БД). DB_HOST=localhost DB_PORT=5432 DB_NAME=calendar_run @@ -6,9 +7,13 @@ DB_USER=calendar_user DB_PASSWORD=calendar_pass # ─── Backend API ────────────────────────────────────────────── -# Port the API server listens on +# Порт: сначала читается PORT (если задан), иначе API_PORT, иначе 3001. +# PORT=3001 API_PORT=3001 +# ─── Dev/CI: без PostgreSQL для smoke API (не для migrate/seed) ─ +# CALENDAR_RUN_MOCK_DB=1 + # ─── CORS ───────────────────────────────────────────────────── # Allowed origin for the frontend (Vite dev server default) CORS_ORIGIN=http://localhost:5173 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c6e8ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: | + backend/package-lock.json + frontend/package-lock.json + + - name: Backend — install, build, test + working-directory: backend + env: + CALENDAR_RUN_MOCK_DB: "1" + run: | + npm ci + npm run build + npm test + + - name: Frontend — install, build + working-directory: frontend + run: | + npm ci + npm run build diff --git a/PLAN.md b/PLAN.md index 4f2735f..034e7ea 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,202 +1,61 @@ -# План: календарь забегов (SPA + API + PostgreSQL) +# Calendar Run — план продукта -Консолидированный план реализации с актуальными решениями. После инициализации git — работать в отдельной ветке (например `feature/race-calendar-app`). +Монорепозиторий: **backend** (Express + PostgreSQL) и **frontend** (React + Vite). Цель — календарь стартов с метриками бегуна: планирование, результаты, PR и сравнение. -## 1. Цели продукта +## Вне объёма (намеренно) -1. **Расписание по месяцу** — выбор месяца/года, список забегов. -2. **Расписание на год** — календарная сетка года, отметки на датах со стартами; по клику на дату — модалка/панель со списком забегов и переход на карточку. -3. Добавлять забеги **запланированные** и **уже прошедшие**. -4. Для **прошедших** — ввод/редактирование **результата** и **стартового номера**; поля можно дописать позже. -5. **Личные рекорды** на главной по дистанциям: 1 км, 5 км, 10 км, 15 км, 21.1 км, 42.2 км — обновляются, если в забеге указан более быстрый результат на «подходящей» дистанции. -6. **Старты (организатор):** дата и время старта, расписание кластеров, выдача номеров — **ручной ввод**; обязательна возможность указать **официальную ссылку** на страницу организатора (автопарсинг сайтов не входит в объём). -7. **Авторизация не требуется.** +- Авторизация, мультипользовательность, личные кабинеты. +- Парсинг сайтов организаторов и автозагрузка результатов. +- Отдача статики SPA с того же процесса, что и API (фронт — отдельный Vite/build). -## 2. Исходные данные в репозитории +## Модель данных `Race` (API — camelCase) -- Файл `import/races_2026_calendar.csv` — колонки `date`, `month`, `day`, `event`, `distance_km`. -- Назначение CSV: **один раз** — как вход для **seed-скрипта**, который пишет строки в PostgreSQL. -- **В рантайме** ни фронт, ни API этот CSV **не читают**. После успешного seed файл может оставаться в репо только как архив/референс. +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | string | Стабильный ключ, например `{YYYY-MM-DD}-{slug}` | +| `date` | string | `YYYY-MM-DD` | +| `title` | string | Название | +| `distanceKm` | number | Дистанция, км | +| `status` | `planned` \| `registered` \| `completed` \| null | Жизненный цикл старта | +| `officialUrl` | string \| null | Сайт организатора | +| `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 | Место на финише (текст: «3», «3/120» и т.п.) | +| `notes` | string \| null | Заметки | +| `createdAt` | string | ISO, read-only | +| `updatedAt` | string \| null | ISO, read-only | -## 3. Стек и структура репозитория +PostgreSQL: `snake_case` столбцы, маппинг в [`backend/src/mappers/race.ts`](backend/src/mappers/race.ts). -- **Монорепозиторий:** `frontend/` (Vite + React 18 + TypeScript + react-router-dom) и `backend/` (Node.js + Fastify или Express). -- **БД:** PostgreSQL; схема — SQL-миграции в `backend/` (или согласованный каталог миграций). -- **Локально:** `docker-compose.yml` с сервисом Postgres. -- **Стили:** CSS + **BEM** + CSS variables (минимальная дизайн-система: цвета, отступы, типографика). -- **Даты/время:** `date-fns` или `Intl`, русская локаль там, где нужно. +## HTTP API (минимум) -### 3.1 Переменные окружения +- `GET /health` — liveness без БД. +- `GET /ready` — readiness (подключение к БД; в режиме mock считается доступной — только для dev/CI). +- `GET /races` — список; query: `year`, `month` (целые; `month` 1–12). +- `GET /races/:id`, `POST /races`, `PATCH /races/:id`, `DELETE /races/:id`. -**Только на сервере API (никогда не во фронт-бандле):** +Ошибки: JSON, единый стиль (`validation_error`, `not_found`, `conflict`, `database_unavailable`). Подробности — [`docs/backend-api-for-frontend.md`](docs/backend-api-for-frontend.md). -```env -DB_HOST= -DB_PORT= -DB_NAME= -DB_USER= -DB_PASSWORD= -``` +## Seed -Дополнительно по необходимости: `PORT` или `API_PORT`, `NODE_ENV`, `CORS_ORIGIN`. +- Файл [`import/races_2026_calendar.csv`](import/races_2026_calendar.csv). +- Стабильный `id`, upsert по `id`. Повторный запуск безопасен. -**Фронтенд (Vite):** только публичный адрес API, например: +## Режим без PostgreSQL (dev/CI) -```env -VITE_API_BASE_URL=http://localhost:3001 -``` +Переменная `CALENDAR_RUN_MOCK_DB=1` (или `true`): HTTP-обработчики используют заглушку пула **без** реальной БД. **Не использовать** для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и `DB_*`. -В коде: `import.meta.env.VITE_API_BASE_URL`. +## Frontend (SPA) -Шаблон без секретов — корневой `.env.example`; описание — в `docs/frontend.md` и `docs/backend.md`. +- Маршруты: дашборд (`/`), список стартов (`/races`), карточка (`/races/:id`). +- Дашборд: ближайший старт, последний результат, PR, сезон, PR по ключевым дистанциям, сравнение завершённых стартов, при необходимости — лёгкая визуализация прогресса. +- Список: будущие / прошедшие; фильтрация по году и месяцу через API. +- Стили: BEM и дизайн-токены; ориентир по духу — [`agent-frontend-ui-instructions.md`](agent-frontend-ui-instructions.md). -## 4. Источник правды и поток данных +## Критерии готовности текущей итерации -### 4.1 Рантайм - -Единственный источник правды для календаря и карточек в работающем приложении — **PostgreSQL**. SPA общается **только с HTTP API**. - -**localStorage не используется** для хранения забегов или «дельт». - -```mermaid -flowchart LR - User[User] - SPA[React SPA] - API[Node API] - DB[(PostgreSQL)] - User --> SPA - SPA -->|"fetch VITE_API_BASE_URL"| API - API -->|"DB_* connection"| DB -``` - - - -### 4.2 Вне рантайма (разово) - -**Seed-скрипт** (запуск вручную при развёртывании/обновлении стартового набора): - -- Читает `import/*.csv` и/или опционально `public/data/races.json`. -- Выполняет `INSERT` / upsert в таблицу `races`. -- Не вызывается из SPA и не выполняется на каждом HTTP-запросе. - -Промежуточная ступень **CSV → races.json для работы SPA не обязательна**: seed может писать в БД напрямую из CSV и/или из JSON. - -### 4.3 Разовый перенос CSV → БД - -- Парсинг: заголовок; кавычки в `event`; `distance_km` — число; `date` — `YYYY-MM-DD`. -- После переноса приложение в обычном режиме **не использует** `import/races_2026_calendar.csv`. Новые старты — через UI/API (или отдельная админ-фича импорта, если появится позже). - -## 5. Модель данных (БД и API) - -Один набор полей — в таблице, в JSON тел запросов/ответов API и в опциональном файле для seed. Согласовать **camelCase в API** и **snake_case в SQL** в `docs/backend.md`. - - -| Поле | Обяз. | Примечание | -| ----------------- | ----- | ---------------------------------------------------- | -| `id` | да | Стабильный ключ, напр. `2026-05-03-kazan-marathon` | -| `date` | да | День старта `YYYY-MM-DD` | -| `title` | да | Название | -| `distanceKm` | да | Число км | -| `status` | нет | `planned` / `completed`; иначе можно вывести из даты | -| `officialUrl` | нет | Ссылка на организатора | -| `startTime` | нет | Напр. `09:30` | -| `clusterSchedule` | нет | Многострочный текст | -| `bibPickup` | нет | Выдача номеров | -| `bibNumber` | нет | Стартовый номер | -| `finishTime` | нет | Время H:MM:SS / HH:MM:SS; для PR — перевод в секунды | -| `notes` | нет | Заметки | - - -Опционально в миграциях: `created_at`, `updated_at`. - -**Операции:** `GET` список/фильтры по году-месяцу при необходимости, `GET :id`, `POST`, `PATCH :id`, при необходимости `DELETE` — зафиксировать в `docs/backend.md`. - -## 6. Поведение SPA - -### 6.1 Форма «Добавить забег» - -- Обязательно: дата, название, дистанция. -- Если дата **строго до сегодня** (локальная дата пользователя) — показать **результат** и **стартовый номер** (необязательны при первом сохранении). -- Если дата в будущем — поля результата скрыты. -- Опционально: чекбокс «Уже прошёл» для случая **сегодняшней** даты, чтобы открыть поля результата. -- Сохранение: `**POST`** на API → запись в БД. - -### 6.2 Страница забега - -- Все поля модели; для прошедших — акцент на результат и номер. -- Редактирование результата и номера **в любой момент**: `**PATCH`** на API, затем обновление состояния на клиенте (refetch / инвалидация кэша). - -### 6.3 Личные рекорды - -- Дистанции: 1, 5, 10, 15, 21.1, 42.2 км. -- Учитывать забеги с заполненным `finishTime` и дистанцией в пределах допуска к целевой (напр. 21.0975 → 21.1, 42.195 → 42.2). -- «Чужие» дистанции (2 км, 6 км, 30 км…) в таблицу PR по умолчанию **не** включаются. -- Расчёт на клиенте по данным, полученным с API. - -### 6.4 Экраны - -1. Главная — PR, навигация «Месяц» / «Год», ближайшие старты. -2. Месяц — селектор, список. -3. Год — сетка, маркеры, клик по дате → модалка со списком → карточка. -4. Карточка забега, форма добавления. -5. Доступность модалки: фокус, Esc, контраст. - -## 7. Документация - -Создать каталог `**docs/`**: - - -| Файл | Назначение | -| ------------------ | ------------------------------------------------------- | -| `docs/backend.md` | Docker, `DB`_*, миграции, seed, REST API, CORS | -| `docs/frontend.md` | Структура `frontend/`, `VITE`_*, сборка, контракт с API | -| `docs/ux-spa.md` | Сценарии, экраны, BEM, кратко про a11y | - - -Корневой `**README.md`**: описание проекта, быстрый старт, **ссылки на три файла выше**. - -## 8. Риски и ограничения - -- `**DB`_* и пароли** — только в окружении сервера API; в git — только `.env.example` без реальных секретов. -- Актуальные данные после работы в UI — в **БД**; файлы в git не синхронизируются с правками автоматически. Надёжный бэкап — **резервное копирование PostgreSQL**. -- Деплой: Postgres + процесс API с env + статическая раздача фронта (или иная схема хостинга — описать в README). -- Данные организаторов (расписание стартов) — **вручную** + ссылка; без парсинга чужих сайтов в объёме первой версии. - -## 9. Ключевые пути после реализации - -- `docker-compose.yml`, `.env.example` -- `backend/` — сервер, миграции, роуты -- `scripts/` или `backend/scripts/` — **разовый** seed из CSV/JSON -- `import/races_2026_calendar.csv` — только вход seed (не рантайм) -- `public/data/races.json` — опциональный вход seed -- `frontend/src/api/` — клиент API -- `frontend/src/pages/`, `frontend/src/components/` — UI (BEM) -- `frontend/src/lib/distances.ts` — PR -- `docs/*.md`, `README.md` - -## 10. Чеклист задач (implementation todos) - -1. Монорепо: `frontend/` + `backend/`, BEM, токены, роутер. -2. Postgres в docker-compose, миграции таблицы `races`, бэкенд читает `DB`_*. -3. REST CRUD + разовый seed (CSV и/или JSON) → БД. -4. Клиент API на фронте, типы, загрузка данных для экранов и PR. -5. Экраны месяц и год, модалка по дате. -6. Форма добавления с условными полями; карточка с PATCH результата/номера. -7. Поля организатора на карточке. -8. A11y модалки, мобильная вёрстка, смоук-сценарии. -9. `docs/` + README + `.env.example`. - -## 11. Идеи на будущее (тематика бега) - -- Недельный объём / простой план подготовки. -- Импорт/экспорт JSON для бэкапа через API. -- Фильтры (город, дистанция), погода по дню старта. -- График динамики PR. -- Загрузка GPX / тренировки. -- Браузерные напоминания за N дней до старта. -- Сравнение результатов одной дистанции по годам. -- PWA для офлайн-просмотра (read-only кэш не заменяет БД без продуманной синхронизации). - ---- - -*Документ создан как единый актуальный план в корне репозитория. При расхождениях с черновиками в IDE приоритет у этого файла.* \ No newline at end of file +- Документация согласована с кодом: [`README.md`](README.md), [`docs/backend.md`](docs/backend.md), [`docs/backend-api-for-frontend.md`](docs/backend-api-for-frontend.md). +- Миграции и seed воспроизводимы; контракт API покрыт smoke-тестами в CI при необходимости с mock-БД. diff --git a/README.md b/README.md index 86448e0..95ee042 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,56 @@ # Calendar Run -Calendar Run is a races calendar project with a backend API for storing and querying race events. +Calendar Run is a races calendar project: a **backend API** (Express + PostgreSQL) and a **React SPA** for viewing and analyzing your race schedule. -## Quick Start +Product scope and data model: [PLAN.md](PLAN.md). + +## Backend — quick start 1. Install dependencies: - - `cd backend` - - `npm install` -2. Configure environment variables in `backend/.env`: - - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - - Optional API port: `PORT` (priority) or `API_PORT` - - **No PostgreSQL (CI / local smoke):** set `CALENDAR_RUN_MOCK_DB=1` (or `true`). Real `DB_`* values are not required; the API uses in-memory stubs for SQL used by the HTTP routes. Do not use mock mode for `npm run db:migrate` or `npm run seed`. -3. Build and run backend: - - `npm run build` - - `npm run dev` + - `cd backend` + - `npm install` +2. Configure environment variables. Copy the root template and edit: -By default, the API listens on port `3001` if `PORT` and `API_PORT` are not set. + ```bash + cp .env.example .env + ``` -## Backend And API Docs + Use the **repository root** `.env` (the backend loads it via `backend/src/config.ts`). + + - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` — required unless mock mode is on. + - API port: `PORT` (takes precedence) or `API_PORT` (default `3001`). + - **No PostgreSQL (CI / local smoke):** set `CALENDAR_RUN_MOCK_DB=1` (or `true`). Real `DB_*` values are not required; the API uses in-memory stubs for SQL used by the HTTP routes. **Do not** use mock mode for `npm run db:migrate` or `npm run seed`. +3. With a real database: from repo root, `docker-compose up -d`, then `cd backend && npm run db:migrate && npm run seed`. +4. Build and run API: + + ```bash + npm run build + npm run dev + ``` + +## Frontend — quick start + +1. Configure the API URL for Vite (file is read from `frontend/`): + + ```bash + cd frontend + cp .env.example .env + ``` + + Edit `VITE_API_BASE_URL` if the API is not on `http://localhost:3001`. + +2. Install and run: + + ```bash + npm install + npm run dev + ``` + + Default app URL: `http://localhost:5173`. The backend `CORS_ORIGIN` must match this origin (see root `.env.example`). + +## Docs - [Backend API for Frontend](docs/backend-api-for-frontend.md) -- [Backend Agent Instruction](docs/agent-backend-instruction.md) -- [Backend Sync Fix Instruction](docs/agent-fix-backend-sync-instruction.md) \ No newline at end of file +- [Backend operations](docs/backend.md) +- [Backend agent instruction](docs/agent-backend-instruction.md) +- [Backend sync fix instruction](docs/agent-fix-backend-sync-instruction.md) diff --git a/backend/migrations/002_finish_place_and_registered_status.sql b/backend/migrations/002_finish_place_and_registered_status.sql new file mode 100644 index 0000000..db13038 --- /dev/null +++ b/backend/migrations/002_finish_place_and_registered_status.sql @@ -0,0 +1,5 @@ +ALTER TABLE races ADD COLUMN IF NOT EXISTS finish_place TEXT; + +ALTER TABLE races DROP CONSTRAINT IF EXISTS races_status_check; +ALTER TABLE races ADD CONSTRAINT races_status_check + CHECK (status IS NULL OR status IN ('planned', 'registered', 'completed')); diff --git a/backend/package-lock.json b/backend/package-lock.json index 6cae516..0d389ba 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,7 +19,10 @@ "@types/express": "^5.0.0", "@types/node": "^22.12.0", "@types/pg": "^8.11.10", + "@types/supertest": "^6.0.2", + "supertest": "^7.0.0", "ts-node": "^10.9.2", + "tsx": "^4.19.2", "typescript": "^5.7.3" } }, @@ -36,6 +39,448 @@ "node": ">=12" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -64,6 +509,29 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -113,6 +581,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -155,13 +630,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -213,6 +694,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -265,6 +770,20 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -327,6 +846,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -363,6 +905,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -402,6 +951,16 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -421,6 +980,17 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -502,6 +1072,64 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -563,6 +1191,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -581,6 +1216,41 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -599,6 +1269,21 @@ "node": ">= 0.6" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -645,6 +1330,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -669,6 +1367,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -852,6 +1566,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -872,7 +1596,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -1048,6 +1771,16 @@ "node": ">= 0.8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1215,6 +1948,90 @@ "node": ">= 0.8" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1268,6 +2085,26 @@ } } }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1287,7 +2124,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1337,6 +2173,13 @@ "node": ">= 0.8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index c34eded..f01bbba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "dev": "ts-node src/index.ts", "start": "node dist/index.js", "db:migrate": "ts-node src/migrate.ts", - "seed": "ts-node src/seed.ts" + "seed": "ts-node src/seed.ts", + "test": "CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts" }, "dependencies": { "cors": "^2.8.5", @@ -21,7 +22,10 @@ "@types/express": "^5.0.0", "@types/node": "^22.12.0", "@types/pg": "^8.11.10", + "@types/supertest": "^6.0.2", + "supertest": "^7.0.0", "ts-node": "^10.9.2", + "tsx": "^4.19.2", "typescript": "^5.7.3" } } diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..faa94db --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,17 @@ +import express from "express"; +import cors from "cors"; +import { config } from "./config"; +import healthRouter from "./routes/health"; +import racesRouter from "./routes/races"; + +export function createApp(): express.Express { + const app = express(); + + app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] })); + app.use(express.json()); + + app.use(healthRouter); + app.use(racesRouter); + + return app; +} diff --git a/backend/src/db.ts b/backend/src/db.ts index bf69619..6d8b4b2 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -29,6 +29,7 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow { bib_pickup: null, bib_number: null, finish_time: null, + finish_place: null, notes: null, created_at: now, updated_at: null, @@ -51,6 +52,7 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow { bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null, bib_number: row.bib_number != null ? String(row.bib_number) : null, finish_time: row.finish_time != null ? String(row.finish_time) : null, + finish_place: row.finish_place != null ? String(row.finish_place) : null, notes: row.notes != null ? String(row.notes) : null, created_at: now, updated_at: null, diff --git a/backend/src/index.ts b/backend/src/index.ts index b7fdd5c..bdf7066 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,16 +1,7 @@ -import express from "express"; -import cors from "cors"; import { config } from "./config"; -import healthRouter from "./routes/health"; -import racesRouter from "./routes/races"; +import { createApp } from "./app"; -const app = express(); - -app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] })); -app.use(express.json()); - -app.use(healthRouter); -app.use(racesRouter); +const app = createApp(); app.listen(config.apiPort, () => { console.log(`[api] Listening on http://localhost:${config.apiPort}`); diff --git a/backend/src/mappers/race.ts b/backend/src/mappers/race.ts index b702c9b..a1c22d5 100644 --- a/backend/src/mappers/race.ts +++ b/backend/src/mappers/race.ts @@ -11,6 +11,7 @@ export interface RaceRow { bib_pickup: string | null; bib_number: string | null; finish_time: string | null; + finish_place: string | null; notes: string | null; created_at: string; updated_at: string | null; @@ -29,6 +30,7 @@ export interface RaceDto { bibPickup: string | null; bibNumber: string | null; finishTime: string | null; + finishPlace: string | null; notes: string | null; createdAt: string; updatedAt: string | null; @@ -48,6 +50,7 @@ export function rowToDto(row: RaceRow): RaceDto { bibPickup: row.bib_pickup, bibNumber: row.bib_number, finishTime: row.finish_time, + finishPlace: row.finish_place, notes: row.notes, createdAt: row.created_at, updatedAt: row.updated_at, @@ -66,6 +69,7 @@ const FIELD_MAP: Record = { bibPickup: "bib_pickup", bibNumber: "bib_number", finishTime: "finish_time", + finishPlace: "finish_place", notes: "notes", }; diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts new file mode 100644 index 0000000..46b74ee --- /dev/null +++ b/backend/test/app.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import request from "supertest"; +import { createApp } from "../src/app"; + +const app = createApp(); + +test("GET /health returns ok", async () => { + const res = await request(app).get("/health").expect(200); + assert.equal(res.body.status, "ok"); +}); + +test("GET /ready succeeds with mock database", async () => { + const res = await request(app).get("/ready").expect(200); + assert.equal(res.body.status, "ready"); + assert.equal(res.body.db, "connected"); +}); + +test("GET /races rejects invalid year", async () => { + const res = await request(app).get("/races?year=bad").expect(400); + assert.equal(res.body.error, "validation_error"); + assert.ok(Array.isArray(res.body.details)); +}); + +test("GET /races rejects month out of range", async () => { + const res = await request(app).get("/races?month=13").expect(400); + assert.equal(res.body.error, "validation_error"); +}); + +test("GET /races accepts year and month", async () => { + const res = await request(app).get("/races?year=2026&month=5").expect(200); + assert.ok(Array.isArray(res.body)); +}); + +test("GET /races/:id returns not_found", async () => { + const res = await request(app).get("/races/does-not-exist").expect(404); + assert.equal(res.body.error, "not_found"); + assert.ok(Array.isArray(res.body.details)); +}); diff --git a/docs/agent-backend-instruction.md b/docs/agent-backend-instruction.md index a7c63c9..12cbacf 100644 --- a/docs/agent-backend-instruction.md +++ b/docs/agent-backend-instruction.md @@ -10,7 +10,7 @@ 2. **Не блокировать работу отсутствием живой БД у исполнителя:** - код миграций и seed должен быть **валидным и согласованным** с PLAN; - при старте API при невозможности подключиться к БД — **ясное сообщение в лог** и **корректный HTTP-ответ** на зависящих от БД маршрутах (например 503 с телом `{"error":"database_unavailable",...}`) **или** падение процесса на старте с понятной ошибкой (выбрать одну стратегию и описать её в `docs/backend.md`). -3. **Не выдумывать обход БД** (in-memory «режим без Postgres»), если это не согласовано отдельно — фронт и план рассчитаны на PostgreSQL. +3. **Режим без Postgres для dev/CI** согласован с [PLAN.md](../PLAN.md) и `docs/backend.md`: переменная `CALENDAR_RUN_MOCK_DB=1` (или `true`) включает in-memory заглушку пула **только** для HTTP-слоя. Для **`npm run db:migrate`** и **`npm run seed`** нужен реальный PostgreSQL и `DB_*`; mock для миграций/seed не используется. 4. Автотесты, требующие Docker/Postgres, помечать как **опциональные** или давать инструкцию «как прогнать локально», не считая провал из-за отсутствия БД у агента блокером для merge кода. --- @@ -90,7 +90,7 @@ **В JSON API (запрос/ответ)** — **camelCase**, как в PLAN: -`id`, `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `notes`. +`id`, `date`, `title`, `distanceKm`, `status` (`planned` \| `registered` \| `completed`), `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`. **В PostgreSQL** — **snake_case**, например: @@ -107,6 +107,7 @@ | `bib_pickup` | `TEXT` | | `bib_number` | `TEXT` | | `finish_time` | `TEXT` | +| `finish_place` | `TEXT` | | `notes` | `TEXT` | | `created_at` | `TIMESTAMPTZ` DEFAULT now() | | `updated_at` | `TIMESTAMPTZ` | diff --git a/docs/backend-api-for-frontend.md b/docs/backend-api-for-frontend.md index 316394d..c9f4537 100644 --- a/docs/backend-api-for-frontend.md +++ b/docs/backend-api-for-frontend.md @@ -87,6 +87,7 @@ GET /races?year=2026&month=5 "bibPickup": null, "bibNumber": null, "finishTime": null, + "finishPlace": null, "notes": null, "createdAt": "2026-03-31T12:00:00.000Z", "updatedAt": null @@ -102,10 +103,10 @@ GET /races?year=2026&month=5 **Ответ 200:** объект `Race` (см. модель ниже). -**Ответ 404:** +**Ответ 404:** тело JSON, поле `details` — массив пояснений (можно показывать в UI или игнорировать). ```json -{ "error": "not_found" } +{ "error": "not_found", "details": ["Race not found"] } ``` --- @@ -129,6 +130,7 @@ GET /races?year=2026&month=5 "bibPickup": null, "bibNumber": null, "finishTime": null, + "finishPlace": null, "notes": null } ``` @@ -160,12 +162,13 @@ GET /races?year=2026&month=5 ```json { "finishTime": "1:45:30", + "finishPlace": "12/340", "bibNumber": "1234", "status": "completed" } ``` -**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `notes`. +**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`. **Ответ 200:** обновлённый объект `Race`. @@ -178,7 +181,7 @@ GET /races?year=2026&month=5 **Ответ 404:** ```json -{ "error": "not_found" } +{ "error": "not_found", "details": ["Race not found"] } ``` --- @@ -192,7 +195,7 @@ GET /races?year=2026&month=5 **Ответ 404:** ```json -{ "error": "not_found" } +{ "error": "not_found", "details": ["Race not found"] } ``` ## 4. Модель `Race` (camelCase) @@ -203,13 +206,14 @@ GET /races?year=2026&month=5 | `date` | string | да | да | `YYYY-MM-DD` | | `title` | string | да | да | Название забега | | `distanceKm` | number | да | да | Дистанция в км | -| `status` | string \| null | нет | да | `"planned"` / `"completed"` | +| `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` | +| `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) | @@ -229,6 +233,6 @@ Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert** ## 7. Поведение при недоступной БД - `GET /health` — всегда `200` (не проверяет БД). -- `GET /ready` — `503 { "error": "database_unavailable", "db": "disconnected" }`. +- `GET /ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`. - Все остальные маршруты — `503 { "error": "database_unavailable" }`. - В логах сервера: строка ошибки с контекстом маршрута. diff --git a/docs/backend.md b/docs/backend.md index 4d4af2a..f8b382d 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -62,27 +62,31 @@ npm run build # компиляция в dist/ npm start # запуск из dist/ ``` -API слушает порт из `API_PORT` (по умолчанию `3001`). +API слушает порт: **`PORT`** (если задан), иначе **`API_PORT`**, иначе **`3001`**. ## Переменные окружения | Переменная | Описание | По умолчанию | |---|---|---| -| `DB_HOST` | Хост PostgreSQL | — (обязательна) | -| `DB_PORT` | Порт PostgreSQL | — (обязательна) | -| `DB_NAME` | Имя базы данных | — (обязательна) | -| `DB_USER` | Пользователь БД | — (обязательна) | -| `DB_PASSWORD` | Пароль БД | — (обязательна) | +| `DB_HOST` | Хост PostgreSQL | — (обязательна без mock, см. ниже) | +| `DB_PORT` | Порт PostgreSQL | — (обязательна без mock) | +| `DB_NAME` | Имя базы данных | — (обязательна без mock) | +| `DB_USER` | Пользователь БД | — (обязательна без mock) | +| `DB_PASSWORD` | Пароль БД | — (обязательна без mock) | +| `CALENDAR_RUN_MOCK_DB` | `1` или `true` — режим без PostgreSQL для HTTP API (dev/CI) | выкл. | +| `PORT` | Порт API (приоритетнее `API_PORT`) | — | | `API_PORT` | Порт API-сервера | `3001` | | `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` | -При отсутствии любой из `DB_*` процесс падает при старте с сообщением `Missing required environment variable: `. +**Без mock:** при отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: `. + +**С `CALENDAR_RUN_MOCK_DB=1`:** переменные `DB_*` не обязательны; реальный пул не поднимается. **Не использовать** mock для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и корректные `DB_*`. ## Поведение при недоступной БД - **Старт сервера** — проходит успешно (env валидирован, Express слушает порт). - **`GET /health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД). -- **`GET /ready`** — пробует подключиться к БД; возвращает `200` если ОК, `503 { "error": "database_unavailable" }` если нет. +- **`GET /ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API). - **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`. ## Структура каталога @@ -90,11 +94,13 @@ API слушает порт из `API_PORT` (по умолчанию `3001`). ``` backend/ ├── migrations/ -│ └── 001_create_races.sql +│ ├── 001_create_races.sql +│ └── 002_finish_place_and_registered_status.sql ├── src/ +│ ├── app.ts # фабрика Express (тесты) │ ├── config.ts # загрузка и валидация env -│ ├── db.ts # pg Pool -│ ├── index.ts # точка входа Express +│ ├── db.ts # pg Pool или mock +│ ├── index.ts # запуск сервера │ ├── migrate.ts # раннер миграций │ ├── seed.ts # разовый импорт CSV │ ├── mappers/ diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..93496e6 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# Base URL of the Calendar Run API (must match CORS_ORIGIN on the backend) +VITE_API_BASE_URL=http://localhost:3001 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e92eb4..20cac47 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -52,7 +52,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1169,7 +1168,6 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1187,7 +1185,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1257,7 +1254,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1534,7 +1530,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1547,7 +1542,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1730,7 +1724,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/api/races.ts b/frontend/src/api/races.ts index 4a4bbae..7143b8f 100644 --- a/frontend/src/api/races.ts +++ b/frontend/src/api/races.ts @@ -18,13 +18,17 @@ function normalizeRace(input: unknown): Race { isString(race?.date) && isString(race?.title) && typeof race?.distanceKm === "number" && - (race?.status === null || race?.status === "planned" || race?.status === "completed") && + (race?.status === null || + race?.status === "planned" || + race?.status === "registered" || + race?.status === "completed") && isNullableString(race?.officialUrl) && isNullableString(race?.startTime) && isNullableString(race?.clusterSchedule) && isNullableString(race?.bibPickup) && isNullableString(race?.bibNumber) && isNullableString(race?.finishTime) && + isNullableString(race?.finishPlace) && isNullableString(race?.notes) && isString(race?.createdAt) && (race?.updatedAt === null || isString(race?.updatedAt)); @@ -49,6 +53,7 @@ function normalizeRace(input: unknown): Race { bibPickup: race.bibPickup, bibNumber: race.bibNumber, finishTime: race.finishTime, + finishPlace: race.finishPlace, notes: race.notes, createdAt: race.createdAt, updatedAt: race.updatedAt, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f2fa8f1..527905b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,4 +1,4 @@ -export type RaceStatus = "planned" | "completed"; +export type RaceStatus = "planned" | "registered" | "completed"; export interface Race { id: string; @@ -12,6 +12,7 @@ export interface Race { bibPickup: string | null; bibNumber: string | null; finishTime: string | null; + finishPlace: string | null; notes: string | null; createdAt: string; updatedAt: string | null; @@ -34,6 +35,7 @@ export interface CreateRacePayload { bibPickup?: string | null; bibNumber?: string | null; finishTime?: string | null; + finishPlace?: string | null; notes?: string | null; } diff --git a/frontend/src/components/PaceTrendChart.tsx b/frontend/src/components/PaceTrendChart.tsx new file mode 100644 index 0000000..8040297 --- /dev/null +++ b/frontend/src/components/PaceTrendChart.tsx @@ -0,0 +1,73 @@ +import type { Race } from "../api"; +import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds } from "../lib"; + +type PaceTrendChartProps = { + races: Race[]; + distanceKm: number; +}; + +/** + * Minimal SVG sparkline: finish time (minutes) over chronological completed races + * at the selected distance. Lower time = higher point (better). + */ +export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element { + const { races, distanceKm } = props; + + const series = races + .filter( + (race) => + race.status === "completed" && + isCloseDistance(race.distanceKm, distanceKm) && + parseFinishTimeToSeconds(race.finishTime) != null, + ) + .sort( + (a, b) => new Date(`${a.date}T00:00:00`).getTime() - new Date(`${b.date}T00:00:00`).getTime(), + ) + .map((race) => { + const seconds = parseFinishTimeToSeconds(race.finishTime)!; + return { race, minutes: seconds / 60 }; + }); + + if (series.length < 2) { + return ( +

+ Нужно минимум два завершённых старта с временем на выбранной дистанции. +

+ ); + } + + const minutes = series.map((s) => s.minutes); + const minM = Math.min(...minutes); + const maxM = Math.max(...minutes); + const range = maxM - minM || 1; + const n = series.length; + + const pad = 4; + const w = 100; + const h = 36; + const innerW = w - pad * 2; + const innerH = h - pad * 2; + + const points = series + .map((s, i) => { + const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW); + const norm = (maxM - s.minutes) / range; + const y = pad + (1 - norm) * innerH; + return `${x},${y}`; + }) + .join(" "); + + const last = series[series.length - 1]!; + + return ( +
+ + + +

+ Последний пункт: {formatRaceDate(last.race.date)} — {last.race.finishTime} ( + {last.minutes.toFixed(1)} мин) +

+
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index cb0ff5c..07eecb3 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1 +1 @@ -export {}; +export { PaceTrendChart } from "./PaceTrendChart"; diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index cc8ea5d..0e5deba 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -3,7 +3,9 @@ export { formatRaceDate, getPaceLabel, getRaceCountdownLabel, + getRaceStatusClassName, getRaceStatusLabel, + isCloseDistance, parseFinishTimeToSeconds, sortByDateAsc, sortByDateDesc, diff --git a/frontend/src/lib/raceMetrics.ts b/frontend/src/lib/raceMetrics.ts index d51793e..76d9228 100644 --- a/frontend/src/lib/raceMetrics.ts +++ b/frontend/src/lib/raceMetrics.ts @@ -89,6 +89,10 @@ export function getRaceCountdownLabel(date: string, now: Date = new Date()): str return `через ${days} дней`; } +export function isCloseDistance(left: number, right: number): boolean { + return Math.abs(left - right) < 0.05; +} + export function getPaceLabel(finishTime: string | null, distanceKm: number): string | null { const totalSeconds = parseFinishTimeToSeconds(finishTime); if (!totalSeconds || distanceKm <= 0) { @@ -102,9 +106,23 @@ export function getPaceLabel(finishTime: string | null, distanceKm: number): str return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`; } +export function getRaceStatusClassName(status: Race["status"]): string { + const base = "race-card__status"; + if (status === "completed") { + return `${base} ${base}--completed`; + } + if (status === "registered") { + return `${base} ${base}--registered`; + } + return `${base} ${base}--planned`; +} + export function getRaceStatusLabel(status: Race["status"]): string { if (status === "completed") { return "пробежал"; } + if (status === "registered") { + return "зарегистрирован"; + } return "планирую"; } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 066d99f..4b18c54 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState } from "react"; import type { Race } from "../api"; import { ApiError, getRaces } from "../api"; +import { PaceTrendChart } from "../components"; import { formatDistance, formatRaceDate, getRaceCountdownLabel, getPaceLabel, + isCloseDistance, parseFinishTimeToSeconds, splitRacesByDate, } from "../lib"; @@ -19,14 +21,11 @@ function getErrorMessage(error: unknown): string { return "Не удалось загрузить данные dashboard."; } -function isSameDistance(left: number, right: number): boolean { - return Math.abs(left - right) < 0.05; -} - export function DashboardPage(): JSX.Element { const [races, setRaces] = useState([]); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); + const [chartDistanceKm, setChartDistanceKm] = useState(10); useEffect(() => { let isMounted = true; @@ -98,7 +97,7 @@ export function DashboardPage(): JSX.Element { const candidates = races.filter((race) => { return ( race.status === "completed" && - isSameDistance(race.distanceKm, distanceKm) && + isCloseDistance(race.distanceKm, distanceKm) && Boolean(parseFinishTimeToSeconds(race.finishTime)) ); }); @@ -136,7 +135,7 @@ export function DashboardPage(): JSX.Element { distance: formatDistance(race.distanceKm), finishTime: race.finishTime ?? "время не указано", pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить", - place: "нет данных", + place: race.finishPlace?.trim() ? race.finishPlace : "нет данных", })) .sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU")); }, [races]); @@ -218,6 +217,30 @@ export function DashboardPage(): JSX.Element { +
+

Прогресс по времени

+

+ Линия по завершённым стартам выбранной дистанции: выше — лучше время (короче гонка). +

+ + +
+

PR по дистанциям

diff --git a/frontend/src/pages/RaceDetailsPage.tsx b/frontend/src/pages/RaceDetailsPage.tsx index 39cb6a9..17039d4 100644 --- a/frontend/src/pages/RaceDetailsPage.tsx +++ b/frontend/src/pages/RaceDetailsPage.tsx @@ -1,7 +1,13 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { ApiError, getRaceById } from "../api"; -import { formatDistance, formatRaceDate, getPaceLabel, getRaceStatusLabel } from "../lib"; +import { + formatDistance, + formatRaceDate, + getPaceLabel, + getRaceStatusClassName, + getRaceStatusLabel, +} from "../lib"; import type { Race } from "../api"; function getErrorMessage(error: unknown): string { @@ -91,15 +97,7 @@ export function RaceDetailsPage(): JSX.Element { {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}

- - {getRaceStatusLabel(race.status)} - + {getRaceStatusLabel(race.status)}
@@ -133,6 +131,12 @@ export function RaceDetailsPage(): JSX.Element {
Темп
{paceLabel ?? "не удалось вычислить"}
+
+
Место
+
+ {race.finishPlace?.trim() ? race.finishPlace : "не указано"} +
+
Стартовый номер
{race.bibNumber ?? "не указан"}
diff --git a/frontend/src/pages/RacesPage.tsx b/frontend/src/pages/RacesPage.tsx index 1fcba88..0dc06a2 100644 --- a/frontend/src/pages/RacesPage.tsx +++ b/frontend/src/pages/RacesPage.tsx @@ -1,8 +1,41 @@ import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; -import type { Race } from "../api"; +import type { Race, RacesQuery } from "../api"; import { ApiError, getRaces } from "../api"; -import { formatDistance, formatRaceDate, getRaceStatusLabel, splitRacesByDate } from "../lib"; +import { + formatDistance, + formatRaceDate, + getRaceStatusClassName, + getRaceStatusLabel, + splitRacesByDate, +} from "../lib"; + +const MONTH_OPTIONS: { value: string; label: string }[] = [ + { value: "", label: "Все месяцы" }, + { value: "1", label: "Январь" }, + { value: "2", label: "Февраль" }, + { value: "3", label: "Март" }, + { value: "4", label: "Апрель" }, + { value: "5", label: "Май" }, + { value: "6", label: "Июнь" }, + { value: "7", label: "Июль" }, + { value: "8", label: "Август" }, + { value: "9", label: "Сентябрь" }, + { value: "10", label: "Октябрь" }, + { value: "11", label: "Ноябрь" }, + { value: "12", label: "Декабрь" }, +]; + +function yearSelectOptions(): number[] { + const current = new Date().getFullYear(); + const start = current - 2; + const end = current + 4; + const years: number[] = []; + for (let y = start; y <= end; y += 1) { + years.push(y); + } + return years; +} function getErrorMessage(error: unknown): string { if (error instanceof ApiError) { @@ -31,15 +64,7 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element { {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}

- - {getRaceStatusLabel(race.status)} - + {getRaceStatusLabel(race.status)} ))} @@ -54,13 +79,33 @@ export function RacesPage(): JSX.Element { const [races, setRaces] = useState([]); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); + const [yearFilter, setYearFilter] = useState(""); + const [monthFilter, setMonthFilter] = useState(""); + + const listQuery = useMemo((): RacesQuery | undefined => { + const q: RacesQuery = {}; + if (yearFilter !== "") { + const y = parseInt(yearFilter, 10); + if (!Number.isNaN(y)) { + q.year = y; + } + } + if (monthFilter !== "") { + const m = parseInt(monthFilter, 10); + if (!Number.isNaN(m)) { + q.month = m; + } + } + return Object.keys(q).length > 0 ? q : undefined; + }, [yearFilter, monthFilter]); useEffect(() => { let isMounted = true; async function loadRaces(): Promise { + setIsLoading(true); try { - const items = await getRaces(); + const items = await getRaces(listQuery); if (!isMounted) { return; } @@ -82,20 +127,11 @@ export function RacesPage(): JSX.Element { return () => { isMounted = false; }; - }, []); + }, [listQuery]); const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]); - if (isLoading) { - return ( -
-

Календарь стартов

-

Загружаем данные...

-
- ); - } - - if (errorMessage) { + if (errorMessage && races.length === 0 && !isLoading) { return (

Календарь стартов

@@ -109,6 +145,48 @@ export function RacesPage(): JSX.Element {

Календарь стартов

Будущие и прошедшие старты в одном месте.

+
+ + +
+ + {isLoading ? ( +

+ Загружаем данные... +

+ ) : null} +
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 724112f..0888cf1 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -144,6 +144,14 @@ a { font-size: var(--font-size-h2); } +.dashboard-section__intro { + margin: calc(var(--space-2) * -1) 0 var(--space-4); + max-width: 42rem; + color: var(--color-text-muted); + font-size: var(--font-size-caption); + line-height: 1.5; +} + .dashboard-grid--pr { margin-top: 0; } @@ -258,6 +266,74 @@ a { color: var(--color-success); } +.race-card__status--registered { + background: #fff4e0; + color: #8a5a00; +} + +.races-filter { + margin-top: var(--space-5); + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + align-items: flex-end; +} + +.races-filter__field { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 10rem; +} + +.races-filter__label { + font-size: var(--font-size-caption); + font-weight: 600; + color: var(--color-text-muted); +} + +.races-filter__select { + font: inherit; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); +} + +.pace-chart { + margin-top: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + background: #fcfdff; +} + +.pace-chart__svg { + display: block; + width: 100%; + max-width: 560px; + height: auto; +} + +.pace-chart__line { + stroke: var(--color-accent); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.pace-chart__caption { + margin: var(--space-3) 0 0; + font-size: var(--font-size-caption); + color: var(--color-text-muted); +} + +.pace-chart__empty { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); + font-size: var(--font-size-body); +} + .page-link { margin-top: var(--space-4); display: inline-flex;