Compare commits

..

81 Commits

Author SHA1 Message Date
Vakanaut
e68df524ef fix: run migrations before starting backend 2026-07-12 17:07:32 +03:00
ea7b8aff7a Merge pull request 'feature/profile-usernames' (#42) from feature/profile-usernames into main
Reviewed-on: admin/runners-calendar#42
2026-07-12 14:01:49 +00:00
Vakanaut
77790a646b Merge remote-tracking branch 'origin/main' into feature/profile-usernames
# Conflicts:
#	backend/package-lock.json
#	backend/package.json
#	backend/src/authMiddleware.ts
#	backend/src/authService.ts
#	backend/src/routes/auth.ts
#	backend/src/routes/users.ts
#	backend/test/app.test.ts
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/src/api/auth.ts
#	frontend/src/api/index.ts
#	frontend/src/api/types.ts
#	frontend/src/api/users.ts
#	frontend/src/app/router.tsx
#	frontend/src/pages/AccountPage.tsx
#	frontend/src/pages/PublicProfilePage.tsx
2026-07-12 17:01:23 +03:00
Vakanaut
d4c72115d8 feat: customize public profile visibility 2026-07-12 16:57:43 +03:00
Vakanaut
103e3ca209 feat: add public user profiles 2026-07-12 16:51:18 +03:00
e68265d9f7 Merge pull request 'feat: add public user profiles' (#41) from feature/public-profiles into main
Reviewed-on: admin/runners-calendar#41
2026-07-12 13:29:56 +00:00
Vakanaut
07ab687bf3 feat: add public user profiles 2026-07-12 16:21:53 +03:00
69931e81a8 Merge pull request 'feat: complete account and dashboard sprint' (#40) from feature/p1-account-dashboard into main
Reviewed-on: admin/runners-calendar#40
2026-07-12 12:59:09 +00:00
Vakanaut
793d51fdce feat: complete account and dashboard sprint 2026-07-12 15:56:26 +03:00
a6108ea927 Merge pull request 'feat: complete p0 hardening sprint' (#39) from feature/p0-hardening-auth-results into main
Reviewed-on: admin/runners-calendar#39
2026-07-12 12:13:45 +00:00
Vakanaut
a317c9b760 feat: complete p0 hardening sprint 2026-07-12 15:08:00 +03:00
b0ebdeca72 Merge pull request 'docs: add versioned backlog and changelog' (#38) from feature/versioned-backlog into main
Reviewed-on: admin/runners-calendar#38
2026-07-12 06:28:25 +00:00
Vaka.pro
c30d6b6041 docs: add versioned backlog and changelog 2026-07-12 09:26:20 +03:00
5122ac9d1b Merge pull request 'feature/registration-auth' (#37) from feature/registration-auth into main
Reviewed-on: admin/runners-calendar#37
2026-05-24 11:36:51 +00:00
Vaka.pro
83fa04ecf2 Merge origin/main into feature/registration-auth 2026-05-24 14:34:45 +03:00
Vaka.pro
fb246e2e55 fix: harden authentication security 2026-05-24 14:27:22 +03:00
a22bb7e471 Merge pull request 'feat: add mobile race list tabs' (#36) from feature/mobile-race-list-tabs into main
Reviewed-on: admin/runners-calendar#36
2026-05-24 11:13:12 +00:00
Vaka.pro
85aff823fc feat: add mobile race list tabs 2026-05-24 14:10:09 +03:00
Vaka.pro
35c3554742 feat: add registration and authentication 2026-05-21 00:01:35 +03:00
13dd8fa426 Merge pull request 'fix: remove fallback image from dashboard race hero' (#35) from fix/dashboard-hero-background-layering into main
Reviewed-on: admin/runners-calendar#35
2026-04-27 21:40:55 +00:00
Vaka.pro
f62be600cd fix: remove fallback image from dashboard race hero 2026-04-28 00:40:01 +03:00
0f5249726b Merge pull request 'fix: use next race image as dashboard hero background' (#34) from fix/dashboard-hero-race-background into main
Reviewed-on: admin/runners-calendar#34
fix: use next race image as dashboard hero background
Set the dashboard hero background from the nearest upcoming race visual, using the existing race visual fallback chain. Add a BEM modifier for the image-backed hero state and bump the frontend patch version.
2026-04-27 20:31:25 +00:00
Vaka.pro
fdb0ba3d2d fix: use next race image as dashboard hero background 2026-04-27 23:30:36 +03:00
367868cf1b Merge pull request 'fix: tolerate missing race cover image field' (#33) from fix/race-cover-api-backcompat into main
Reviewed-on: admin/runners-calendar#33
2026-04-27 20:08:11 +00:00
Vaka.pro
78d0ab5ece fix: tolerate missing race cover image field 2026-04-27 23:07:31 +03:00
e2eb71522d Merge pull request 'feat: add race cover image extraction' (#32) from feature/race-cover-images into main
Reviewed-on: admin/runners-calendar#32
2026-04-27 20:02:08 +00:00
Vaka.pro
00985732ec Merge remote-tracking branch 'origin/main' into feature/race-cover-images
# Conflicts:
#	frontend/package-lock.json
#	frontend/package.json
2026-04-27 23:01:19 +03:00
Vaka.pro
0153f223f2 feat: add race cover image extraction 2026-04-27 22:56:41 +03:00
b1b363a7e8 Merge pull request 'feat(frontend): add service favicon' (#31) from feat/add-service-favicon into main
Reviewed-on: admin/runners-calendar#31
2026-04-27 11:29:02 +00:00
Anton
f5e16c44b3 feat(frontend): add service favicon 2026-04-27 14:26:42 +03:00
c5ca511ea7 Merge pull request 'chore: fix versioning' (#30) from chore/frontend-version-0.5.1 into main
Reviewed-on: admin/runners-calendar#30
2026-04-27 11:20:31 +00:00
Anton
42057ddb1c chore: fix versioning 2026-04-27 14:20:05 +03:00
1a37afd16f Merge pull request 'fix(frontend): prevent calendar loading layout shift' (#29) from fix/calendar-loading-layout-shift into main
Reviewed-on: admin/runners-calendar#29
2026-04-27 11:03:46 +00:00
Anton
f7b611bbbe fix(frontend): prevent calendar loading layout shift 2026-04-27 14:02:20 +03:00
55fc23ec64 Merge pull request 'fix frontend calendar race states' (#28) from codex/calendar-race-ui-fixes into main
Reviewed-on: admin/runners-calendar#28
2026-04-27 09:32:39 +00:00
Anton
dffbb48d99 fix frontend calendar race states 2026-04-27 12:31:29 +03:00
0b7ad23252 Merge pull request 'chore: resizes images' (#27) from chore/resize-images into main
Reviewed-on: admin/runners-calendar#27
2026-04-22 10:27:38 +00:00
Anton
19e9e59125 chore: resizes images 2026-04-22 12:50:39 +03:00
bfbbaeae59 Merge pull request 'feat(frontend): redesign race dashboard' (#26) from feature/sport-dashboard-redesign into main
Reviewed-on: admin/runners-calendar#26
2026-04-22 08:48:30 +00:00
Anton
0da7454033 feat(frontend): redesign race dashboard 2026-04-22 11:47:37 +03:00
7b0267f9ac Merge pull request 'fix(frontend): hide calendar popups on empty dates' (#25) from fix/calendar-hide-popover-empty-days into main
Reviewed-on: admin/runners-calendar#25
2026-04-13 19:59:16 +00:00
Vaka.pro
a581ffaaff fix(frontend): hide calendar popups on empty dates
Keep race popovers limited to dates with events so empty days no longer show misleading hover details.

Made-with: Cursor
2026-04-13 22:58:36 +03:00
429a2924d7 Merge pull request 'fix(frontend): animate full race list row on hover (li, not inner link)' (#24) from fix/race-list-card-hover into main
Reviewed-on: admin/runners-calendar#24
2026-04-13 19:51:07 +00:00
Vaka.pro
afb0f7ef31 fix(frontend): animate full race list row on hover (li, not inner link)
Move scale/shadow transition to .race-card--action; keyboard focus ring on link.
Version 0.4.2.

Made-with: Cursor
2026-04-13 22:47:06 +03:00
92c2360feb Merge pull request 'fix(frontend): auto-completed on finish time, dashboard links, list/calendar UX' (#23) from fix/dashboard-form-races-ux into main
Reviewed-on: admin/runners-calendar#23
2026-04-13 19:36:10 +00:00
Vaka.pro
4ea8faf16f fix(frontend): auto-completed on finish time, dashboard links, list/calendar UX
- Set status to completed when finish time parses (input + submit)
- Dashboard: last personal record by recent date+time; links on top 3 cards
- Hover scale+shadow on all dashboard-card; linked card padding via BEM
- Race list: full row links to race detail; same hover as before
- Calendar year grid: 3 columns, 2 on tablet, 1 on narrow
- Version 0.4.1

Made-with: Cursor
2026-04-13 22:34:39 +03:00
74f059593e Merge pull request 'feat(frontend): race form, start time selects, calendar views, day page' (#22) from feat/race-ui-plan-implementation into main
Reviewed-on: admin/runners-calendar#22
2026-04-13 19:09:11 +00:00
Vaka.pro
3c6baa66a1 feat(frontend): race form, start time selects, calendar views, day page
- Hide org schedule fields when editing a past race; isRaceDateInPast helper
- StartTimeSelects (HH:mm:ss) and optional ?date= prefill on new race
- Full-card link to edit for races needing result entry; shadow token
- List/calendar toggle (sessionStorage); year grid and month focus views
- Date hover popover and /races/day/:ymd page with Add button
- Docs plan-korrektirovok-starty.md and startTime API note; client 0.4.0

Made-with: Cursor
2026-04-13 22:07:37 +03:00
b997dcb01e Merge pull request 'chore: bump patch versions; remove temp request logging' (#21) from fix/docker-api-upstream-ambiguity into main
Reviewed-on: admin/runners-calendar#21
2026-04-12 15:56:52 +00:00
Vaka.pro
e033b2c8d5 chore: bump patch versions; remove temp request logging
Frontend 0.3.1, backend 1.2.2; drop debug middleware from app.ts.

Made-with: Cursor
2026-04-12 18:51:00 +03:00
c337823fa8 Merge pull request 'fix(docker): use unique Compose service name for API upstream' (#20) from fix/docker-api-upstream-ambiguity into main
Reviewed-on: admin/runners-calendar#20
2026-04-12 15:43:25 +00:00
Vaka.pro
a4f8c37b84 fix(docker): use unique Compose service name for API upstream
Rename stack service from backend to runners-calendar-backend so DNS on
shared external networks (e.g. postgres_default) cannot resolve to
another project’s backend. Nginx proxy_pass targets the same hostname.

Made-with: Cursor
2026-04-12 18:39:04 +03:00
7e980dd802 Merge pull request 'chore(backend): log Host/Origin and status for request debugging' (#19) from fix/temp-req-headers-log into main
Reviewed-on: admin/runners-calendar#19
2026-04-10 19:29:27 +00:00
Vaka.pro
c04dc35075 chore(backend): log Host/Origin and status for request debugging 2026-04-10 22:26:53 +03:00
a41408559e Merge pull request 'refactor(api): unify /api contract across frontend, nginx, and backend' (#18) from fix/intermittent-api-retry-cache into main
Reviewed-on: admin/runners-calendar#18
2026-04-08 09:19:43 +00:00
Anton
8eaf006906 refactor(api): unify /api contract across frontend, nginx, and backend 2026-04-08 11:59:46 +03:00
9f63b190f1 Merge pull request 'feat: /meta для версии в футере и устойчивый разбор JSON' (#17) from feat/footer-backend-meta into main
Reviewed-on: admin/runners-calendar#17
2026-04-08 07:33:37 +00:00
Anton
83bc603b95 feat: /meta для версии в футере и устойчивый разбор JSON 2026-04-08 10:32:52 +03:00
f8b4ce7111 Merge pull request 'fix(api): дублировать маршруты под /api и убрать Content-Type у GET' (#16) from fix/api-prefix-routing into main
Reviewed-on: admin/runners-calendar#16
2026-04-08 07:21:03 +00:00
Anton
53b9561a54 fix(api): дублировать маршруты под /api и убрать Content-Type у GET 2026-04-08 10:20:17 +03:00
7e9c20d4bf Merge pull request 'fix: прод — CORS, версия API, ошибки клиента и подсказка по прошедшим стартам' (#15) from fix/prod-cors-health-status-hints into main
Reviewed-on: admin/runners-calendar#15
2026-04-07 22:21:49 +00:00
Vaka.pro
e0ed0b6435 fix: прод — CORS, версия API, ошибки клиента и подсказка по прошедшим стартам
- CORS_ORIGIN: несколько origin через запятую; комментарии в .env.example
- Версия бэкенда: APP_VERSION, безопасное чтение package.json, футер при пустой версии
- Сообщения API: unknown_error и ответы 401/403/404 без JSON; отладочный лог при !ok
- Статус «внесите результат» для прошедшей даты + блок на карточке старта и стили
2026-04-08 01:21:11 +03:00
8442c761c2 Merge pull request 'fix(frontend): bundle app version via package.json import instead of Vite define' (#14) from fix/vite-frontend-version-define into main
Reviewed-on: admin/runners-calendar#14
2026-04-07 21:55:25 +00:00
Vaka.pro
87d6505fbf fix(frontend): bundle app version via package.json import instead of Vite define 2026-04-08 00:45:24 +03:00
99ae7410ce Merge pull request 'feat: русский UI, версии в футере, даты и устойчивость загрузки API' (#13) from feat/ru-ui-footer-versions-dates-api into main
Reviewed-on: admin/runners-calendar#13
2026-04-07 21:40:47 +00:00
Vaka.pro
42ee36d0a2 feat: русский UI, версии в футере, даты и устойчивость загрузки API
- API: дата старта всегда YYYY-MM-DD; фронт: parseRaceDate без двойного T00:00:00
- GET /health с version из package.json; Vite define __FRONTEND_VERSION__
- Футер с версиями клиента/сервера (BEM), сетка app-shell на три ряда
- AbortController для карточки старта; ретраи GET при 502–504 и понятные ошибки шлюза
- Русские подписи навигации/страниц, lang=ru, без английских фраз в интерфейсе
2026-04-08 00:40:03 +03:00
fc995ed07d Merge pull request 'fix(seed): resolve CSV path for Docker and mount import in stack compose; deleted plans; adds extra files to gitignore' (#12) from fix/seed-csv-path-docker-import-mount into main
Reviewed-on: admin/runners-calendar#12
2026-04-07 21:22:53 +00:00
Vaka.pro
0f0c7c2607 fix(seed): resolve CSV path for Docker and mount import in stack compose; deleted plans; adds extra files to gitignore 2026-04-08 00:22:11 +03:00
ef423b322f Merge pull request 'chore: delete empty features module, fix RaceRow timestamp types' (#11) from chore/phase4-cleanup-types into main
Reviewed-on: admin/runners-calendar#11
2026-04-07 15:38:45 +00:00
d77dc205dc Merge pull request 'feat: CRUD UI — race form, detail fields, edit/delete actions' (#10) from feat/phase3-crud-ui into main
Reviewed-on: admin/runners-calendar#10
2026-04-07 15:38:14 +00:00
8a7e87385a Merge pull request 'docs: fix README dev/production commands, update FRONTEND_PLAN status, remove broken links' (#9) from docs/phase2-fix-documentation into main
Reviewed-on: admin/runners-calendar#9
2026-04-07 15:37:41 +00:00
b92fad6939 Merge pull request 'fix: phase 1 bugs — CSS tokens, pluralization, error handling, cross-platform tests' (#8) from fix/phase1-bugs-and-critical-fixes into main
Reviewed-on: admin/runners-calendar#8
2026-04-07 15:37:11 +00:00
Anton
7260fb59ea chore: delete empty features module, fix RaceRow timestamp types
- Remove dead frontend/src/features/index.ts (empty export, unused)

- RaceRow: created_at/updated_at typed as Date to match pg TIMESTAMPTZ runtime

- rowToDto: explicit toISOString() conversion instead of relying on JSON.stringify

- Mock DB: return Date objects for timestamp fields to match real pg behavior

Made-with: Cursor
2026-04-07 18:35:11 +03:00
Anton
4b63af8da5 feat: CRUD UI — race form, detail fields, edit/delete actions
- RaceDetailsPage: show all non-null fields (officialUrl, startTime, clusterSchedule, bibPickup)

- RaceDetailsPage: add edit link and delete button with confirmation banner

- RaceFormPage: universal create/edit form with validation, auto-generated id for new races

- Router: add /races/new and /races/:raceId/edit routes

- AppLayout: add navigation link to create new race

- CSS: buttons (primary/secondary/danger), form fields, confirm banner, responsive layout

Made-with: Cursor
2026-04-07 18:13:22 +03:00
Anton
f74ce6ed88 docs: fix README dev/production commands, update FRONTEND_PLAN status, remove broken links
- README: separate dev (npm run dev) and production (npm run build && npm start) steps

- FRONTEND_PLAN: mark backend-dependency-task as completed (finishPlace already in API)

- Remove dead references to missing agent-frontend-ui-instructions.md from PLAN.md and FRONTEND_PLAN.md

Made-with: Cursor
2026-04-07 17:51:02 +03:00
Anton
3b8f41f905 fix: phase 1 bugs — CSS tokens, pluralization, error handling, cross-platform tests
- Add missing --space-1 CSS token used by filter and detail components

- Fix active nav link losing styles on hover (CSS specificity)

- Correct Russian day pluralization (21 день, 22 дня, 25 дней)

- Show filter error banner even when stale race data is present

- Add cross-env for Windows-compatible npm test

- Add global JSON error handler in Express for malformed request bodies

- Replace stateless mock DB with in-memory store for correct DELETE/UPDATE behavior

Made-with: Cursor
2026-04-07 17:46:46 +03:00
b422223c03 Merge pull request 'chore: drop agent/plan docs, unify .env for Docker stack' (#7) from chore/cleanup-docker-docs-env into main
Reviewed-on: admin/runners-calendar#7
2026-04-06 21:31:33 +00:00
Vaka.pro
007d899721 chore: drop agent/plan docs, unify .env for Docker stack
- 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
2026-04-07 00:30:29 +03:00
2cf01186e9 Merge pull request 'chore(docker): stack compose with env_file, no secrets in repo' (#6) from feature/docker-stack into main
Reviewed-on: admin/runners-calendar#6
2026-04-06 20:26:37 +00:00
Vaka.pro
2849b16e36 chore(docker): stack compose with env_file, no secrets in repo
- docker-compose.stack.env for DB_* and CORS (gitignored)
- docker-compose.stack.env.example with placeholders
- .dockerignore excludes local stack env from build context

Made-with: Cursor
2026-04-06 23:03:45 +03:00
d187bc776e Merge pull request 'feat: align docs with code, finish_place, registered status, UI filters, tests, CI' (#5) from feature/plan-docs-product-tests into main
Reviewed-on: admin/runners-calendar#5
2026-04-06 19:41:20 +00:00
98 changed files with 8541 additions and 1451 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.github
**/node_modules
**/dist
.env
.env.*
!.env.example
**/*.md
.cursor
*.log

View File

@@ -1,23 +1,63 @@
# ─── PostgreSQL ───────────────────────────────────────────────
# Не обязательны, если CALENDAR_RUN_MOCK_DB=1 (только HTTP API без БД).
# =============================================================================
# Корневой шаблон окружения: локальная разработка, docker-compose.yml (Postgres)
# и docker-compose.stack.yml (backend + frontend в общей сети с Postgres).
# Скопируйте в .env и подставьте значения. Файл .env не коммитьте.
# =============================================================================
# ─── Подключение бэкенда к PostgreSQL ──────────────────────────
# Локально + docker-compose из этого репо: DB_HOST=localhost, DB_PORT=5432
# Backend в Docker рядом с Postgres: DB_HOST = имя контейнера/сервиса в той же
# docker-сети (например postgres_budget), DB_PORT=5432 (внутренний порт Postgres).
DB_HOST=localhost
DB_PORT=5432
DB_NAME=calendar_run
DB_USER=calendar_user
DB_PASSWORD=calendar_pass
DB_PASSWORD=replace_with_strong_secret
# ─── Backend API ──────────────────────────────────────────────
# Порт: сначала читается PORT (если задан), иначе API_PORT, иначе 3001.
# PORT=3001
# ─── Backend API ──────────────────────────────────────────────
# Порт процесса: сначала PORT, иначе API_PORT, иначе 3001.
# В docker-compose.stack.yml для контейнера задаётся PORT=3000 (см. compose).
# PORT=3000
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 в браузере (схема + хост + порт, без пути), иначе API «молчит».
# Локальный Vite: http://localhost:5173
# Стек с фронтом на 3033: http://localhost:3033
# Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru
CORS_ORIGIN=http://localhost:5173
# ─── Frontend (Vite) ─────────────────────────────────────────
# Public URL of the API, used in SPA code via import.meta.env
VITE_API_BASE_URL=http://localhost:3001
# ─── Auth / sessions ─────────────────────────────────────────
# APP_BASE_URL is the only source for verify/reset email links.
APP_BASE_URL=http://localhost:5173
SESSION_SECRET=replace_with_32plus_char_random_secret
# Production defaults to __Host-sid + Secure cookies. Local dev can stay insecure over http.
# SESSION_COOKIE_NAME=__Host-sid
# SESSION_COOKIE_SECURE=true
# SESSION_TTL_DAYS=30
# AUTH_CLEANUP_INTERVAL_HOURS=24
# ─── Cloudflare Turnstile ────────────────────────────────────
TURNSTILE_SECRET_KEY=replace_with_turnstile_secret
# Local tests/dev only, rejected in production:
# TURNSTILE_BYPASS_TOKEN=mock-turnstile-token
# ─── SMTP email ──────────────────────────────────────────────
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=replace_with_smtp_user
SMTP_PASSWORD=replace_with_smtp_password
SMTP_FROM=Calendar Run <no-reply@example.com>
# ─── Seed after auth ─────────────────────────────────────────
# Required once users exist, so seed never creates ownerless races or overwrites user edits.
# SEED_OWNER_USER_ID=
# SEED_OWNER_EMAIL=
# ─── Версия API (опционально) ─────────────────────────────────
# Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health).
# APP_VERSION=1.0.0
# ─── Frontend (Vite, локально из каталога frontend/) ─────────
# Браузер всегда ходит на относительный /api; в dev это проксирует Vite.

View File

@@ -9,6 +9,20 @@ on:
jobs:
build-and-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: calendar_run_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d calendar_run_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
@@ -23,10 +37,18 @@ jobs:
- name: Backend — install, build, test
working-directory: backend
env:
CALENDAR_RUN_MOCK_DB: "1"
NODE_ENV: test
DB_HOST: 127.0.0.1
DB_PORT: "5432"
DB_NAME: calendar_run_test
DB_USER: postgres
DB_PASSWORD: postgres
SESSION_SECRET: test-session-secret
TURNSTILE_BYPASS_TOKEN: mock-turnstile-token
run: |
npm ci
npm run build
npm run db:migrate
npm test
- name: Frontend — install, build

6
.gitignore vendored
View File

@@ -2,5 +2,7 @@ node_modules/
dist/
.env
*.log
*plan*
*PLAN*
*.tsbuildinfo
vite.config.js
vite.config.d.ts
.DS_Store

188
CHANGELOG.md Normal file
View File

@@ -0,0 +1,188 @@
# Changelog
Все заметки о выпусках Calendar Run. Backend и frontend имеют независимые версии.
Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя.
Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md).
## В работе
### Backend 1.5.0
#### Added
- Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий.
### Frontend 0.8.0
#### Added
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление другими активными сессиями.
- На дашборде появились фильтруемые «Последние старты» с пагинацией и переходом в старт, ссылки из карточек рекордов и интерактивный график прогресса.
## Backend
### [1.4.2] — 2026-07-12
#### Changed
- CI и тесты используют настоящий PostgreSQL с миграциями; самодельный SQL mock удалён.
- Контракт API стартов актуализирован: UUID `id`, пользовательский `slug`, авторизация, CSRF и строгая валидация запросов.
- Получение обложки по сайту организатора ограничено безопасными публичными HTTP(S)-адресами, редиректами, размером и временем ответа.
### [1.4.1] — 2026-05-24
#### Changed
- Усилена безопасность аутентификации, сессий и production security profile.
- Добавлены проверки, предотвращающие использование dev-bypass Turnstile в production.
### [1.4.0] — 2026-05-24
#### Added
- Регистрация, вход/выход, подтверждение email, повторная отправка письма и сброс пароля.
- Cookie-сессии, CSRF-защита, rate limit и Turnstile.
- Владение стартами: пользователь видит и изменяет только свои записи.
- SMTP-уведомления и очистка истёкших auth-сущностей.
### [1.3.0] — 2026-04-27
#### Added
- Необязательное автоматическое извлечение обложки старта по сайту организатора.
- Поле `coverImageUrl` в модели, миграции и API.
### [1.2.2] — 2026-04-12
#### Changed
- Patch-релиз: удалено временное диагностическое логирование запросов.
### [1.2.1] — 2026-04-10
#### Added
- Временная диагностика `Host`/`Origin` и статуса ответа для расследования проблем подключения.
### [1.2.0] — 2026-04-08
#### Changed
- Унифицирован контракт API на префиксе `/api` для Express, Vite и nginx.
### [1.1.0] — 2026-04-08
#### Added
- `GET /api/meta` для отображения серверной версии в интерфейсе.
### [1.0.1] — 2026-04-08
#### Fixed
- Совместимость маршрутов API с прокси и клиентским префиксом `/api`.
### [1.0.0] — 2026-04-01
#### Added
- Базовый Express API для стартов, PostgreSQL-миграции, seed из CSV и health/readiness endpoints.
## Frontend
### [0.7.1] — 2026-07-12
#### Changed
- Формы используют нативные поля даты и времени старта; результаты вводятся отдельными полями времени и места.
- Подтверждение email завершается явным экраном успеха, auth-формы показывают состояние отправки и ошибки.
- Карточка личного рекорда отображается только при реальном улучшении результата.
- Удалены сгенерированные Vite/TypeScript-файлы, неиспользуемый API-клиент и статический каталог обложек.
### [0.7.0] — 2026-05-24
#### Added
- Экраны регистрации, входа, подтверждения email и сброса пароля.
- Защищённые маршруты, состояние сессии и выход из аккаунта.
### [0.6.2] — 2026-05-24
#### Added
- Табы «Будущие» и «Прошедшие» для компактного мобильного списка стартов.
### [0.6.1] — 2026-04-27
#### Changed
- Обложка ближайшего старта используется фоном hero-блока дашборда.
### [0.6.0] — 2026-04-27
#### Added
- Поддержка пользовательской и автоматически полученной обложки старта в списках и карточке.
### [0.5.2] — 2026-04-27
#### Added
- Favicon сервиса.
### [0.5.1] — 2026-04-27
#### Fixed
- Корректное определение версии frontend из `package.json`.
### [0.5.0] — 2026-04-22
#### Changed
- Редизайн дашборда и визуального представления стартов.
### [0.4.0] — 2026-04-13
#### Added
- Форма старта с селектами времени, просмотр календаря годом и месяцем, страница конкретного дня.
- Предзаполнение даты при добавлении из календаря.
### [0.3.1] — 2026-04-12
#### Fixed
- Исправления UX списка и календаря, автоматическая установка статуса «Пробежал» при валидном времени финиша.
### [0.3.0] — 2026-04-08
#### Changed
- Единый клиентский путь `/api` и устойчивый разбор ответов сервера.
### [0.2.0] — 2026-04-08
#### Added
- Русский UI и футер с версиями frontend и backend.
### [0.1.1] — 2026-04-08
#### Fixed
- Получение версии приложения из `package.json` вместо Vite define.
### [0.1.0] — 2026-04-06
#### Added
- React/Vite приложение, типизированный API-клиент, список и карточка стартов, формы CRUD, дашборд и базовая аналитика результатов.
## Как добавлять запись при выпуске
1. Изменить версию соответствующего пакета по SemVer и обновить lockfile.
2. Добавить только фактически выпущенные изменения в раздел `Backend` или `Frontend`.
3. Если изменился API — обновить [docs/backend-api-for-frontend.md](docs/backend-api-for-frontend.md) в том же PR.
4. Перед выпуском проверить, что футер показывает новую версию frontend и фактическую версию backend.

22
Dockerfile.backend Normal file
View File

@@ -0,0 +1,22 @@
# Сборка из корня монорепо: docker build -f Dockerfile.backend .
FROM node:20-alpine AS deps
WORKDIR /app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci
FROM deps AS build
COPY backend/tsconfig.json ./
COPY backend/src ./src
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY backend/migrations ./migrations
COPY import ./import
EXPOSE 3000
ENV PORT=3000
CMD ["sh", "-c", "node dist/migrate.js && node dist/index.js"]

13
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,13 @@
# Сборка из корня монорепо: docker build -f Dockerfile.frontend .
# SPA дергает API по префиксу /api (nginx проксирует на сервис runners-calendar-backend:3000).
FROM node:20-alpine AS build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY docker/nginx.frontend.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

61
PLAN.md
View File

@@ -1,61 +0,0 @@
# Calendar Run — план продукта
Монорепозиторий: **backend** (Express + PostgreSQL) и **frontend** (React + Vite). Цель — календарь стартов с метриками бегуна: планирование, результаты, PR и сравнение.
## Вне объёма (намеренно)
- Авторизация, мультипользовательность, личные кабинеты.
- Парсинг сайтов организаторов и автозагрузка результатов.
- Отдача статики SPA с того же процесса, что и API (фронт — отдельный Vite/build).
## Модель данных `Race` (API — camelCase)
| Поле | Тип | Описание |
|------|-----|----------|
| `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 |
PostgreSQL: `snake_case` столбцы, маппинг в [`backend/src/mappers/race.ts`](backend/src/mappers/race.ts).
## HTTP API (минимум)
- `GET /health` — liveness без БД.
- `GET /ready` — readiness (подключение к БД; в режиме mock считается доступной — только для dev/CI).
- `GET /races` — список; query: `year`, `month` (целые; `month` 112).
- `GET /races/:id`, `POST /races`, `PATCH /races/:id`, `DELETE /races/:id`.
Ошибки: JSON, единый стиль (`validation_error`, `not_found`, `conflict`, `database_unavailable`). Подробности — [`docs/backend-api-for-frontend.md`](docs/backend-api-for-frontend.md).
## Seed
- Файл [`import/races_2026_calendar.csv`](import/races_2026_calendar.csv).
- Стабильный `id`, upsert по `id`. Повторный запуск безопасен.
## Режим без PostgreSQL (dev/CI)
Переменная `CALENDAR_RUN_MOCK_DB=1` (или `true`): HTTP-обработчики используют заглушку пула **без** реальной БД. **Не использовать** для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и `DB_*`.
## Frontend (SPA)
- Маршруты: дашборд (`/`), список стартов (`/races`), карточка (`/races/:id`).
- Дашборд: ближайший старт, последний результат, PR, сезон, PR по ключевым дистанциям, сравнение завершённых стартов, при необходимости — лёгкая визуализация прогресса.
- Список: будущие / прошедшие; фильтрация по году и месяцу через API.
- Стили: BEM и дизайн-токены; ориентир по духу — [`agent-frontend-ui-instructions.md`](agent-frontend-ui-instructions.md).
## Критерии готовности текущей итерации
- Документация согласована с кодом: [`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-БД.

View File

@@ -1,56 +1,46 @@
# Calendar Run
Calendar Run is a races calendar project: a **backend API** (Express + PostgreSQL) and a **React SPA** for viewing and analyzing your race schedule.
Монорепозиторий: **backend** (Express + PostgreSQL) и **frontend** (React + Vite) — календарь забегов.
Product scope and data model: [PLAN.md](PLAN.md).
## Переменные окружения
## Backend — quick start
Один шаблон для локальной разработки и для Docker-стека: `**[.env.example](.env.example)`** → скопируйте в `**.env**` в корне репозитория.
1. Install dependencies:
- `cd backend`
- `npm install`
2. Configure environment variables. Copy the root template and edit:
Там же перечислены `**DB_HOST**`, `**DB_PORT**`, `**DB_NAME**`, `**DB_USER**`, `**DB_PASSWORD**` (подключение бэкенда к БД), `**PORT**` / `**API_PORT**` и `**CORS_ORIGIN**`.
```bash
cp .env.example .env
```
## Backend — локально
Use the **repository root** `.env` (the backend loads it via `backend/src/config.ts`).
1. `cd backend && npm install`
2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`).
3. Postgres: из корня `docker compose up -d` (см. `[docker-compose.yml](docker-compose.yml)`) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`.
4. `cd backend && npm run db:migrate && npm run seed`
5. Dev-режим: `npm run dev`
6. Или production: `npm run build && npm start`
- `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:
## Frontend — локально
```bash
npm run build
npm run dev
```
```bash
cd frontend
cp .env.example .env
npm install
npm run dev
```
## Frontend — quick start
Фронт всегда отправляет запросы на относительный префикс `**/api**`. В dev это проксирует Vite на `http://localhost:3001`, в Docker/проде — nginx фронта проксирует на `runners-calendar-backend:3000`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
1. Configure the API URL for Vite (file is read from `frontend/`):
## Docker: backend + frontend рядом с Postgres
```bash
cd frontend
cp .env.example .env
```
Используйте `[docker-compose.stack.yml](docker-compose.stack.yml)`: общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть `**.env`** (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и `**CORS_ORIGIN=http://localhost:3033**`, если заходите на фронт с хоста на порту 3033.
Edit `VITE_API_BASE_URL` if the API is not on `http://localhost:3001`.
```bash
docker compose -f docker-compose.stack.yml up -d --build
docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/migrate.js
docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/seed.js
```
2. Install and run:
Фронт в браузере обращается к API по префиксу `**/api**` (nginx в образе фронта проксирует на сервис `runners-calendar-backend` в той же сети).
```bash
npm install
npm run dev
```
## Документация API и бэкенда
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 operations](docs/backend.md)
- [Backend agent instruction](docs/agent-backend-instruction.md)
- [Backend sync fix instruction](docs/agent-fix-backend-sync-instruction.md)
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
- [Эксплуатация backend](docs/backend.md)

View File

@@ -1,336 +0,0 @@
Ниже — инструкция для дизайн-агента, которая задаёт стиль, структуру и принципы интерфейса для твоего SPA. Она ориентирована на **минимализм, читаемость данных и удовольствие от использования**, а не на “перегруженный фитнес-дэшборд”.
# 🎯 Общая задача
Создать **чистый, современный, минималистичный интерфейс** для SPA-приложения бегуна, где пользователь:
- отслеживает свои старты (прошлые и будущие),
- видит прогресс и личные рекорды,
- сравнивает результаты,
- получает ощущение “контроля над своей беговой историей”.
---
# 🧭 Общая философия дизайна
### 1. “Спокойная сила”, а не “спортивная агрессия”
Не:
- кислотные цвета,
- перегруженные графики,
- “фитнес-клуб стиль 2015”.
Да:
- чистый интерфейс,
- воздух,
- акцент на данных,
- ощущение премиальности.
---
### 2. Минимум визуального шума
Каждый экран должен отвечать на вопрос:
> “Что здесь главное?”
Если не ясно — убрать лишнее.
---
### 3. Данные — главный герой
Интерфейс не про “украшения”, а про:
- результаты,
- прогресс,
- сравнение,
- числа.
---
# 🎨 Визуальный стиль
## Цвета
### Основа:
- фон: светлый (#F7F8FA или близко)
- карточки: белые
- текст:
- основной: почти чёрный (#111)
- вторичный: серый (#666)
### Акцент:
- 1 основной цвет:
- глубокий синий или изумрудный (например #2F6BFF или #1FA37A)
### Дополнительно:
- PR / успех: мягкий зелёный
- предупреждения: нейтральный жёлтый
- ошибки: мягкий красный
❗ Никаких “радуг”.
---
## Типографика
- современный sans-serif (Inter / SF Pro / аналог)
- крупные числа:
- результаты (время) — большие, жирные
- иерархия:
- H1 — экран
- H2 — блок
- body — данные
- caption — мета
---
## Отступы и сетка
- много воздуха
- карточки с padding 1624px
- равномерная вертикальная ритмика
- закругления: 1216px
---
# 🧱 Основные экраны
## 1. Главный экран (Dashboard)
### Цель:
дать быстрый ответ:
- что впереди
- где я сейчас
- какой прогресс
### Блоки:
#### 🔹 Ближайший старт
- название
- дата
- дистанция
- countdown (“через 12 дней”)
#### 🔹 Последний результат
- время
- дистанция
- место
- дата
#### 🔹 Личный рекорд
- по ключевой дистанции
- выделен визуально
#### 🔹 Сезон
- количество стартов
- лучший результат
- краткая статистика
---
## 2. Календарь стартов
### Два раздела:
- будущие
- прошедшие
### Карточка старта:
- название
- дата
- дистанция
- статус:
- планирую
- зарегистрирован
- пробежал
Минимум кликов.
---
## 3. Карточка старта
- название
- дата
- дистанция
- время
- темп
- место (если есть)
- заметки (опционально)
---
## 4. Личные рекорды (PR)
- список дистанций:
- 5K
- 10K
- 21.1
- 42.2
Для каждой:
- лучшее время
- дата
- старт
---
## 5. Сравнение стартов (ВАЖНО)
Это ключевая фича.
### UI:
таблица или карточки:
| Год | Время | Темп | Место |
| --- | ----- | ---- | ----- |
Можно добавить:
- стрелки (лучше/хуже)
- визуальный прогресс
---
# 📊 Графики
Минимально и аккуратно:
- линия прогресса по дистанции
- без перегруза
- без 10 линий сразу
---
# 🧩 Компоненты
- карточки
- таблицы (очень аккуратно)
- фильтры
- переключатели (tabs)
- кнопки (primary / secondary)
---
# 🧠 UX принципы
### 1. Минимум кликов
Любая частая задача:
→ максимум 23 клика
---
### 2. Быстрое считывание
Пользователь должен за 2 секунды понять:
- что это
- где он
- что делать
---
### 3. Консистентность
- одинаковые карточки
- одинаковые статусы
- одинаковые действия
---
# 🧪 Для аналитики (важно)
Заложить:
- зоны кликов
- понятные CTA
- отсутствие “пустых зон”
---
# 🖼️ Использование изображений
## Когда использовать:
- пустые состояния
- onboarding
- вдохновение
## Когда НЕ использовать:
- в данных
- в списках стартов
- в результатах
---
## Если генерировать изображения
Стиль:
- минимализм
- спорт без пафоса
- одиночный бегун
- город / парк
- мягкий свет
- утро / вечер
### Пример prompt:
> “minimalist photo of a runner jogging alone in a city park at sunrise, soft light, calm mood, no crowd, modern aesthetic”
---
# 🚫 Чего избегать
- перегруженных дашбордов
- 100 метрик сразу
- ярких кислотных цветов
- сложных графиков
- таблиц как в Excel
- лишних иконок
- “геймификации ради геймификации”
---
# 💡 Вдохновение (по духу)
Если бы нужно было описать стиль:
- как Notion, но для бегунов
- как Apple Fitness, но менее ярко
- как Strava, но более спокойно и чисто
---
# 🧭 Финальный ориентир
Интерфейс должен вызывать ощущение:
> “Я контролирую свою беговую историю и прогресс”
а не:
> “Я заполняю таблицу результатов”
---

View File

@@ -0,0 +1,2 @@
ALTER TABLE races
ADD COLUMN IF NOT EXISTS cover_image_url TEXT;

View File

@@ -0,0 +1,114 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_normalized_key
ON users (LOWER(BTRIM(email)));
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
csrf_token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions(user_id);
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
ON email_verification_tokens(user_id);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
ON password_reset_tokens(user_id);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
DO $$
DECLARE
id_type TEXT;
pk_name TEXT;
BEGIN
SELECT data_type INTO id_type
FROM information_schema.columns
WHERE table_name = 'races' AND column_name = 'id';
IF id_type IS NOT NULL AND id_type <> 'uuid' THEN
SELECT conname INTO pk_name
FROM pg_constraint
WHERE conrelid = 'races'::regclass AND contype = 'p'
LIMIT 1;
IF pk_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE races DROP CONSTRAINT %I', pk_name);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'races' AND column_name = 'slug'
) THEN
ALTER TABLE races RENAME COLUMN id TO slug;
ELSE
ALTER TABLE races DROP COLUMN id;
END IF;
END IF;
END $$;
ALTER TABLE races ADD COLUMN IF NOT EXISTS id UUID DEFAULT gen_random_uuid();
UPDATE races SET id = gen_random_uuid() WHERE id IS NULL;
ALTER TABLE races ALTER COLUMN id SET NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'races'::regclass AND contype = 'p'
) THEN
ALTER TABLE races ADD CONSTRAINT races_pkey PRIMARY KEY (id);
END IF;
END $$;
ALTER TABLE races ADD COLUMN IF NOT EXISTS slug TEXT;
UPDATE races SET slug = id::text WHERE slug IS NULL OR BTRIM(slug) = '';
ALTER TABLE races ALTER COLUMN slug SET NOT NULL;
ALTER TABLE races ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE races ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'user';
CREATE UNIQUE INDEX IF NOT EXISTS races_owner_slug_key
ON races(owner_user_id, slug)
WHERE owner_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS races_owner_user_id_idx ON races(owner_user_id);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_profile_public BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,12 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_username TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_future_races_public BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_completed_races_public BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users
SET is_future_races_public = is_profile_public,
is_completed_races_public = is_profile_public
WHERE is_profile_public = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS users_profile_username_normalized_key
ON users (LOWER(profile_username))
WHERE profile_username IS NOT NULL;

View File

@@ -1,23 +1,31 @@
{
"name": "calendar-run-backend",
"version": "1.0.0",
"version": "1.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.0.0",
"version": "1.5.3",
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"pg": "^8.13.1"
"express-rate-limit": "^8.4.1",
"helmet": "^8.1.0",
"nodemailer": "^8.0.7",
"pg": "^8.13.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.12.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0",
@@ -39,6 +47,12 @@
"node": ">=12"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -532,6 +546,15 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -581,6 +604,16 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -647,6 +680,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -764,6 +807,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/argon2": {
"version": "0.44.0",
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
"integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@phc/format": "^1.0.0",
"cross-env": "^10.0.0",
"node-addon-api": "^8.5.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -899,6 +958,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -936,6 +1014,37 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/csv-parse": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
@@ -1191,6 +1300,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -1395,6 +1522,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1433,6 +1569,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1442,6 +1587,12 @@
"node": ">= 0.10"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -1533,6 +1684,35 @@
"node": ">= 0.6"
}
},
"node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1585,6 +1765,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -1858,6 +2047,27 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -2173,6 +2383,21 @@
"node": ">= 0.8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -2198,6 +2423,15 @@
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.0.0",
"version": "1.5.3",
"private": true,
"scripts": {
"build": "tsc",
@@ -8,19 +8,27 @@
"start": "node dist/index.js",
"db:migrate": "ts-node src/migrate.ts",
"seed": "ts-node src/seed.ts",
"test": "CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
"test": "tsx --import ./test/setup.ts --test test/app.test.ts"
},
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"pg": "^8.13.1"
"express-rate-limit": "^8.4.1",
"helmet": "^8.1.0",
"nodemailer": "^8.0.7",
"pg": "^8.13.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.12.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0",

View File

@@ -1,17 +1,71 @@
import express from "express";
import express, { Request, Response, NextFunction } from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import { config } from "./config";
import { loadAuth, requireCsrf } from "./authMiddleware";
import authRouter from "./routes/auth";
import healthRouter from "./routes/health";
import racesRouter from "./routes/races";
import usersRouter from "./routes/users";
const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
export function buildHelmetOptions(securityProfile: string) {
return {
contentSecurityPolicy:
securityProfile === "production"
? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", TURNSTILE_ORIGIN],
styleSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", TURNSTILE_ORIGIN],
frameSrc: [TURNSTILE_ORIGIN],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
}
: false,
hsts:
securityProfile === "production"
? { maxAge: 31_536_000, includeSubDomains: true }
: false,
referrerPolicy: { policy: "strict-origin-when-cross-origin" as const },
};
}
export function createApp(): express.Express {
const app = express();
app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] }));
app.use(helmet(buildHelmetOptions(config.securityProfile)));
app.use(
cors({
origin: config.corsOrigin,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "X-CSRF-Token"],
}),
);
app.use(express.json());
app.use(cookieParser(config.session.secret));
app.use(loadAuth);
app.use(requireCsrf);
app.use(healthRouter);
app.use(racesRouter);
app.use("/api", healthRouter);
app.use("/api", authRouter);
app.use("/api", usersRouter);
app.use("/api", racesRouter);
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof SyntaxError && "body" in err) {
res.status(400).json({ error: "validation_error", details: ["Invalid JSON in request body"] });
return;
}
console.error("[app] Unhandled error:", err);
res.status(500).json({ error: "unknown_error", details: ["Internal server error"] });
});
return app;
}

View File

@@ -0,0 +1,86 @@
import { NextFunction, Request, Response } from "express";
import { config } from "./config";
import { csrfMatches, getSession } from "./authService";
declare global {
namespace Express {
interface Request {
auth?: {
user: {
id: string;
email: string;
emailVerifiedAt: string | null;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
};
csrfTokenHash: string;
sessionToken: string;
};
}
}
}
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
export function setSessionCookie(res: Response, sessionToken: string): void {
res.cookie(config.session.cookieName, sessionToken, {
httpOnly: true,
secure: config.session.secure,
sameSite: "lax",
path: "/",
maxAge: config.session.ttlDays * 24 * 60 * 60 * 1000,
});
}
export function clearSessionCookie(res: Response): void {
res.clearCookie(config.session.cookieName, {
httpOnly: true,
secure: config.session.secure,
sameSite: "lax",
path: "/",
});
}
export async function loadAuth(req: Request, _res: Response, next: NextFunction): Promise<void> {
const token = req.cookies?.[config.session.cookieName];
if (typeof token !== "string" || token.trim() === "") {
next();
return;
}
try {
const session = await getSession(token);
if (session) {
req.auth = { ...session, sessionToken: token };
}
} catch (error) {
next(error);
return;
}
next();
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (!req.auth) {
res.status(401).json({ error: "unauthorized", details: ["Authentication required"] });
return;
}
if (!req.auth.user.emailVerifiedAt) {
res.status(403).json({ error: "email_not_verified", details: ["Email verification required"] });
return;
}
next();
}
export function requireCsrf(req: Request, res: Response, next: NextFunction): void {
if (!MUTATING_METHODS.has(req.method) || !req.auth) {
next();
return;
}
const token = req.header("X-CSRF-Token");
if (!token || !csrfMatches(req.auth.csrfTokenHash, token)) {
res.status(403).json({ error: "csrf_error", details: ["Invalid CSRF token"] });
return;
}
next();
}

542
backend/src/authService.ts Normal file
View File

@@ -0,0 +1,542 @@
import { PoolClient } from "pg";
import { config } from "./config";
import { pool } from "./db";
import { sendMail } from "./mailer";
import {
addDays,
addHours,
hashPassword,
normalizeEmail,
randomToken,
sha256Hex,
timingSafeEqualHex,
verifyPassword,
} from "./security";
import { anonymizeEmail, securityLog } from "./securityLog";
export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
}
interface UserRow {
id: string;
email: string;
password_hash: string;
email_verified_at: Date | string | null;
profile_username: string | null;
is_future_races_public: boolean;
is_completed_races_public: boolean;
}
interface SessionRow {
id: string;
user_id: string;
token_hash: string;
csrf_token_hash: string;
expires_at: Date | string;
email: string;
email_verified_at: Date | string | null;
profile_username: string | null;
is_future_races_public: boolean;
is_completed_races_public: boolean;
}
interface SessionListRow {
id: string;
created_at: Date | string;
last_seen_at: Date | string;
expires_at: Date | string;
current: boolean;
}
export interface ActiveSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}
function toIso(value: Date | string | null): string | null {
if (!value) {
return null;
}
return value instanceof Date ? value.toISOString() : String(value);
}
function userFromRow(row: UserRow): AuthUser {
return {
id: row.id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
};
}
function appUrl(path: string, token: string): string {
const url = new URL(path, config.appBaseUrl);
url.searchParams.set("token", token);
return url.toString();
}
function isUniqueViolation(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505";
}
export async function findUserByEmail(email: string): Promise<UserRow | null> {
const normalized = normalizeEmail(email);
const { rows } = await pool.query<UserRow>(
`SELECT id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public
FROM users WHERE LOWER(BTRIM(email)) = $1`,
[normalized],
);
return rows[0] ?? null;
}
export async function createVerificationToken(client: PoolClient, userId: string): Promise<string> {
await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL",
[userId],
);
const token = randomToken(32);
await client.query(
`INSERT INTO email_verification_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[userId, sha256Hex(token), addHours(new Date(), 24)],
);
return token;
}
export async function createResetToken(client: PoolClient, userId: string): Promise<string> {
await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL",
[userId],
);
const token = randomToken(32);
await client.query(
`INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[userId, sha256Hex(token), addHours(new Date(), 1)],
);
return token;
}
export async function sendVerificationEmail(email: string, token: string): Promise<void> {
await sendMail(
email,
"Подтвердите email в Calendar Run",
`Откройте ссылку для подтверждения email: ${appUrl("/verify-email", token)}`,
);
}
export async function sendResetEmail(email: string, token: string): Promise<void> {
await sendMail(
email,
"Сброс пароля Calendar Run",
`Откройте ссылку для сброса пароля: ${appUrl("/reset-password", token)}`,
);
}
export async function registerUser(email: string, password: string): Promise<void> {
const normalized = normalizeEmail(email);
const passwordHash = await hashPassword(password);
const client = await pool.connect();
try {
await client.query("BEGIN");
let rows: UserRow[];
try {
({ rows } = await client.query<UserRow>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public`,
[normalized, passwordHash],
));
} catch (error) {
if (isUniqueViolation(error)) {
await client.query("ROLLBACK");
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
return;
}
throw error;
}
const user = rows[0];
if (user) {
const token = await createVerificationToken(client, user.id);
await client.query("COMMIT");
await sendVerificationEmail(user.email, token);
securityLog("register.created", { emailHash: anonymizeEmail(normalized) });
return;
}
await client.query("COMMIT");
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function createSession(userId: string): Promise<{ sessionToken: string; csrfToken: string; user: AuthUser }> {
const sessionToken = randomToken(32);
const csrfToken = randomToken(32);
const expiresAt = addDays(new Date(), config.session.ttlDays);
const { rows } = await pool.query<SessionRow>(
`INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, token_hash, csrf_token_hash, expires_at,
(SELECT email FROM users WHERE id = $1) AS email,
(SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at,
(SELECT profile_username FROM users WHERE id = $1) AS profile_username,
(SELECT is_future_races_public FROM users WHERE id = $1) AS is_future_races_public,
(SELECT is_completed_races_public FROM users WHERE id = $1) AS is_completed_races_public`,
[userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt],
);
const row = rows[0];
securityLog("session.issued", { userId });
return {
sessionToken,
csrfToken,
user: {
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
},
};
}
export async function loginUser(email: string, password: string): Promise<{ ok: false } | { ok: true; sessionToken: string; csrfToken: string; user: AuthUser }> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
const passwordOk = await verifyPassword(user?.password_hash ?? null, password);
if (!user || !passwordOk || !user.email_verified_at) {
securityLog("login.failed", { emailHash: anonymizeEmail(normalized) });
return { ok: false };
}
const session = await createSession(user.id);
securityLog("login.succeeded", { userId: user.id });
return { ok: true, ...session };
}
export async function revokeSession(sessionToken: string): Promise<void> {
await pool.query("UPDATE sessions SET revoked_at = NOW() WHERE token_hash = $1", [sha256Hex(sessionToken)]);
securityLog("session.revoked");
}
export async function listActiveSessions(userId: string, sessionToken: string): Promise<ActiveSession[]> {
const { rows } = await pool.query<SessionListRow>(
`SELECT id, created_at, last_seen_at, expires_at, token_hash = $2 AS current
FROM sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY created_at DESC`,
[userId, sha256Hex(sessionToken)],
);
return rows.map((row) => ({
id: row.id,
createdAt: toIso(row.created_at)!,
lastSeenAt: toIso(row.last_seen_at)!,
expiresAt: toIso(row.expires_at)!,
current: row.current,
}));
}
export async function revokeSessionById(userId: string, sessionId: string, sessionToken: string): Promise<{ revoked: boolean; current: boolean }> {
const { rows } = await pool.query<{ current: boolean }>(
`UPDATE sessions
SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()
RETURNING token_hash = $3 AS current`,
[sessionId, userId, sha256Hex(sessionToken)],
);
const row = rows[0];
if (!row) {
return { revoked: false, current: false };
}
securityLog("session.revoked", { userId });
return { revoked: true, current: row.current };
}
export async function revokeOtherSessions(userId: string, sessionToken: string): Promise<void> {
await pool.query(
`UPDATE sessions
SET revoked_at = NOW()
WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL AND expires_at > NOW()`,
[userId, sha256Hex(sessionToken)],
);
securityLog("session.others_revoked", { userId });
}
export async function changePassword(
userId: string,
sessionToken: string,
currentPassword: string,
newPassword: string,
): Promise<boolean> {
const newPasswordHash = await hashPassword(newPassword);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ password_hash: string }>(
"SELECT password_hash FROM users WHERE id = $1 FOR UPDATE",
[userId],
);
const user = rows[0];
if (!user || !(await verifyPassword(user.password_hash, currentPassword))) {
await client.query("ROLLBACK");
return false;
}
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [userId, newPasswordHash]);
await client.query(
"UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL",
[userId, sha256Hex(sessionToken)],
);
await client.query("COMMIT");
securityLog("password.changed", { userId });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function rotateCsrf(sessionToken: string): Promise<string | null> {
const csrfToken = randomToken(32);
const { rowCount } = await pool.query(
`UPDATE sessions
SET csrf_token_hash = $2, last_seen_at = NOW()
WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
[sha256Hex(sessionToken), sha256Hex(csrfToken)],
);
return rowCount && rowCount > 0 ? csrfToken : null;
}
export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> {
const tokenHash = sha256Hex(sessionToken);
const { rows } = await pool.query<SessionRow>(
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at,
u.profile_username, u.is_future_races_public, u.is_completed_races_public
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > NOW()`,
[tokenHash],
);
const row = rows[0];
if (!row) {
return null;
}
await pool.query("UPDATE sessions SET last_seen_at = NOW(), expires_at = $2 WHERE id = $1", [
row.id,
addDays(new Date(), config.session.ttlDays),
]);
return {
user: {
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
},
csrfTokenHash: row.csrf_token_hash,
};
}
export async function updateProfile(
userId: string,
profile: {
profileUsername?: string | null;
isFutureRacesPublic?: boolean;
isCompletedRacesPublic?: boolean;
},
): Promise<void> {
await pool.query(
`UPDATE users SET
profile_username = CASE WHEN $2 THEN $3 ELSE profile_username END,
is_future_races_public = CASE WHEN $4 THEN $5 ELSE is_future_races_public END,
is_completed_races_public = CASE WHEN $6 THEN $7 ELSE is_completed_races_public END,
updated_at = NOW()
WHERE id = $1`,
[
userId,
profile.profileUsername !== undefined,
profile.profileUsername,
profile.isFutureRacesPublic !== undefined,
profile.isFutureRacesPublic,
profile.isCompletedRacesPublic !== undefined,
profile.isCompletedRacesPublic,
],
);
}
export function csrfMatches(hash: string, token: string): boolean {
return timingSafeEqualHex(hash, sha256Hex(token));
}
export async function verifyEmailToken(token: string): Promise<boolean> {
const tokenHash = sha256Hex(token);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
`SELECT id, user_id, token_hash
FROM email_verification_tokens
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
FOR UPDATE`,
[tokenHash],
);
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
if (!row) {
await client.query("ROLLBACK");
return false;
}
const consumed = await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
[row.id],
);
if (consumed.rowCount === 0) {
await client.query("ROLLBACK");
return false;
}
await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
[row.user_id, row.id],
);
await client.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()), updated_at = NOW() WHERE id = $1", [row.user_id]);
await client.query("SELECT pg_advisory_xact_lock(706365)");
const claimed = await client.query("SELECT value FROM app_settings WHERE key = 'orphan_races_claimed_by_user_id' FOR UPDATE");
if (claimed.rows.length === 0) {
await client.query("UPDATE races SET owner_user_id = $1, updated_at = NOW() WHERE owner_user_id IS NULL", [row.user_id]);
await client.query(
`INSERT INTO app_settings (key, value)
VALUES ('orphan_races_claimed_by_user_id', $1)`,
[row.user_id],
);
}
await client.query("COMMIT");
securityLog("email.verified", { userId: row.user_id });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function requestPasswordReset(email: string): Promise<void> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
if (!user) {
securityLog("password_reset.requested_missing", { emailHash: anonymizeEmail(normalized) });
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const token = await createResetToken(client, user.id);
await client.query("COMMIT");
await sendResetEmail(user.email, token);
securityLog("password_reset.requested", { userId: user.id });
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function resetPassword(token: string, password: string): Promise<boolean> {
const tokenHash = sha256Hex(token);
const passwordHash = await hashPassword(password);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
`SELECT id, user_id, token_hash
FROM password_reset_tokens
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
FOR UPDATE`,
[tokenHash],
);
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
if (!row) {
await client.query("ROLLBACK");
return false;
}
const consumed = await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
[row.id],
);
if (consumed.rowCount === 0) {
await client.query("ROLLBACK");
return false;
}
await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
[row.user_id, row.id],
);
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [row.user_id, passwordHash]);
await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [row.user_id]);
await client.query("COMMIT");
securityLog("password_reset.completed", { userId: row.user_id });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function resendVerification(email: string): Promise<void> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
if (!user || user.email_verified_at) {
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const token = await createVerificationToken(client, user.id);
await client.query("COMMIT");
await sendVerificationEmail(user.email, token);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function cleanupExpiredAuthRows(): Promise<void> {
await pool.query("DELETE FROM sessions WHERE expires_at <= NOW() OR revoked_at < NOW() - INTERVAL '30 days'");
await pool.query("DELETE FROM email_verification_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
await pool.query("DELETE FROM password_reset_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
}
export function startAuthCleanupSchedule(intervalHours = config.authCleanupIntervalHours): NodeJS.Timeout {
const runCleanup = () => {
void cleanupExpiredAuthRows().catch((error) => {
console.error("[auth-cleanup] Failed:", error);
});
};
runCleanup();
const safeHours = Number.isFinite(intervalHours) && intervalHours > 0 ? intervalHours : 24;
const timer = setInterval(runCleanup, safeHours * 60 * 60 * 1000);
timer.unref?.();
return timer;
}

View File

@@ -11,27 +11,81 @@ function requireEnv(name: string): string {
return value;
}
const useMockDb =
process.env.CALENDAR_RUN_MOCK_DB === "1" ||
process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true";
function optionalEnv(name: string): string | null {
const value = process.env[name]?.trim();
return value ? value : null;
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value == null || value.trim() === "") {
return fallback;
}
return value === "1" || value.toLowerCase() === "true";
}
const securityProfile = process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development";
export function resolveTurnstileBypassToken(params: {
rawBypassToken?: string;
securityProfile: string;
}): string {
const raw = params.rawBypassToken?.trim() ?? "";
if (raw && params.securityProfile === "production") {
throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production");
}
return raw;
}
export const config = {
useMockDb,
db: useMockDb
? {
host: "mock",
port: 5432,
database: "mock",
user: "mock",
password: "mock",
}
: {
host: requireEnv("DB_HOST"),
port: parseInt(requireEnv("DB_PORT"), 10),
database: requireEnv("DB_NAME"),
user: requireEnv("DB_USER"),
password: requireEnv("DB_PASSWORD"),
},
db: {
host: requireEnv("DB_HOST"),
port: parseInt(requireEnv("DB_PORT"), 10),
database: requireEnv("DB_NAME"),
user: requireEnv("DB_USER"),
password: requireEnv("DB_PASSWORD"),
},
apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10),
corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173",
/** Одно значение или несколько через запятую (прод: https://домен) */
corsOrigin: parseCorsOrigins(),
appBaseUrl: process.env.APP_BASE_URL?.trim() || "http://localhost:5173",
session: {
secret: requireEnv("SESSION_SECRET"),
cookieName:
process.env.SESSION_COOKIE_NAME?.trim() ||
(process.env.NODE_ENV === "production" ? "__Host-sid" : "sid"),
secure: parseBoolean(process.env.SESSION_COOKIE_SECURE, process.env.NODE_ENV === "production"),
ttlDays: parseInt(process.env.SESSION_TTL_DAYS || "30", 10),
},
smtp: {
host: optionalEnv("SMTP_HOST"),
port: parseInt(process.env.SMTP_PORT || "587", 10),
secure: parseBoolean(process.env.SMTP_SECURE, false),
user: optionalEnv("SMTP_USER"),
password: optionalEnv("SMTP_PASSWORD"),
from: process.env.SMTP_FROM?.trim() || "Calendar Run <no-reply@example.com>",
},
turnstile: {
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || "",
bypassToken: resolveTurnstileBypassToken({
rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN,
securityProfile,
}),
},
authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10),
securityProfile,
};
function parseCorsOrigins(): string | string[] {
const raw = process.env.CORS_ORIGIN?.trim();
if (!raw) {
return "http://localhost:5173";
}
const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) {
return "http://localhost:5173";
}
if (parts.length === 1) {
return parts[0]!;
}
return parts;
}

View File

@@ -1,6 +1,5 @@
import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg";
import { Pool, PoolConfig } from "pg";
import { config } from "./config";
import type { RaceRow } from "./mappers/race";
const poolConfig: PoolConfig = {
host: config.db.host,
@@ -13,121 +12,13 @@ const poolConfig: PoolConfig = {
connectionTimeoutMillis: 5_000,
};
function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
const match = sql.match(/INSERT INTO races\s*\(([^)]+)\)\s*VALUES/i);
const now = new Date().toISOString();
if (!match) {
return {
id: String(params[0] ?? ""),
race_date: "",
title: "",
distance_km: "0",
status: null,
official_url: null,
start_time: null,
cluster_schedule: null,
bib_pickup: null,
bib_number: null,
finish_time: null,
finish_place: null,
notes: null,
created_at: now,
updated_at: null,
};
}
const cols = match[1].split(",").map((c) => c.trim());
const row: Record<string, unknown> = {};
cols.forEach((col, i) => {
row[col] = params[i];
});
return {
id: String(row.id ?? ""),
race_date: String(row.race_date ?? ""),
title: String(row.title ?? ""),
distance_km: String(row.distance_km ?? "0"),
status: row.status != null ? String(row.status) : null,
official_url: row.official_url != null ? String(row.official_url) : null,
start_time: row.start_time != null ? String(row.start_time) : null,
cluster_schedule: row.cluster_schedule != null ? String(row.cluster_schedule) : null,
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,
};
}
export const pool = new Pool(poolConfig);
function createMockPool(): Pool {
const emptyResult = <T extends QueryResultRow>(): QueryResult<T> =>
({
rows: [],
rowCount: 0,
command: "",
oid: 0,
fields: [],
}) as QueryResult<T>;
const mockQuery = async <T extends QueryResultRow>(
text: string,
params?: unknown[],
): Promise<QueryResult<T>> => {
const sql = text.replace(/\s+/g, " ").trim();
const p = params ?? [];
if (sql.includes("DELETE FROM races")) {
return emptyResult();
}
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
const row = mockRowFromInsert(text, p);
return {
rows: [row as unknown as T],
rowCount: 1,
command: "INSERT",
oid: 0,
fields: [],
} as QueryResult<T>;
}
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
return emptyResult();
}
if (sql.includes("SELECT * FROM races")) {
return emptyResult();
}
return emptyResult();
};
const mockPool = {
query: mockQuery,
connect: async () => {
throw new Error(
"CALENDAR_RUN_MOCK_DB is enabled: migrate/seed require a real database; unset CALENDAR_RUN_MOCK_DB and configure DB_*.",
);
},
end: async () => {},
on() {
return mockPool;
},
};
return mockPool as unknown as Pool;
}
export const pool = config.useMockDb ? createMockPool() : new Pool(poolConfig);
if (!config.useMockDb) {
pool.on("error", (err) => {
console.error("[db] Unexpected pool error:", err.message);
});
} else {
console.warn("[db] Mock database enabled (CALENDAR_RUN_MOCK_DB); no PostgreSQL connection is used.");
}
pool.on("error", (err) => {
console.error("[db] Unexpected pool error:", err.message);
});
export async function checkDbConnection(): Promise<boolean> {
if (config.useMockDb) {
return true;
}
try {
const client = await pool.connect();
client.release();

View File

@@ -1,7 +1,9 @@
import { config } from "./config";
import { createApp } from "./app";
import { startAuthCleanupSchedule } from "./authService";
const app = createApp();
startAuthCleanupSchedule();
app.listen(config.apiPort, () => {
console.log(`[api] Listening on http://localhost:${config.apiPort}`);

26
backend/src/mailer.ts Normal file
View File

@@ -0,0 +1,26 @@
import nodemailer from "nodemailer";
import { config } from "./config";
export async function sendMail(to: string, subject: string, text: string): Promise<void> {
if (!config.smtp.host) {
console.info("[mail] SMTP is not configured; email content follows.", { to, subject, text });
return;
}
const transporter = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.secure,
auth:
config.smtp.user && config.smtp.password
? { user: config.smtp.user, pass: config.smtp.password }
: undefined,
});
await transporter.sendMail({
from: config.smtp.from,
to,
subject,
text,
});
}

View File

@@ -1,11 +1,17 @@
/** Row shape returned by PostgreSQL (snake_case). */
/**
* Row shape returned by PostgreSQL (snake_case).
* pg returns DATE as string, NUMERIC as string, TIMESTAMPTZ as Date.
*/
export interface RaceRow {
id: string;
race_date: string;
slug: string;
owner_user_id: string | null;
race_date: string | Date;
title: string;
distance_km: string;
status: string | null;
official_url: string | null;
cover_image_url: string | null;
start_time: string | null;
cluster_schedule: string | null;
bib_pickup: string | null;
@@ -13,18 +19,20 @@ export interface RaceRow {
finish_time: string | null;
finish_place: string | null;
notes: string | null;
created_at: string;
updated_at: string | null;
created_at: Date;
updated_at: Date | null;
}
/** API shape (camelCase). */
export interface RaceDto {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
status: string | null;
officialUrl: string | null;
coverImageUrl: string | null;
startTime: string | null;
clusterSchedule: string | null;
bibPickup: string | null;
@@ -36,15 +44,33 @@ export interface RaceDto {
updatedAt: string | null;
}
function toISOString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : String(value);
}
/** DATE column may arrive as string or Date; API always exposes YYYY-MM-DD for the calendar day. */
function raceDateToApiValue(value: string | Date): string {
if (typeof value === "string") {
const m = value.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1]! : value;
}
const y = value.getFullYear();
const mo = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${y}-${mo}-${day}`;
}
/** Convert a DB row to the API DTO (camelCase). */
export function rowToDto(row: RaceRow): RaceDto {
return {
id: row.id,
date: row.race_date,
slug: row.slug,
date: raceDateToApiValue(row.race_date),
title: row.title,
distanceKm: parseFloat(row.distance_km),
status: row.status,
officialUrl: row.official_url,
coverImageUrl: row.cover_image_url ?? null,
startTime: row.start_time,
clusterSchedule: row.cluster_schedule,
bibPickup: row.bib_pickup,
@@ -52,18 +78,20 @@ export function rowToDto(row: RaceRow): RaceDto {
finishTime: row.finish_time,
finishPlace: row.finish_place,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at,
createdAt: toISOString(row.created_at),
updatedAt: row.updated_at ? toISOString(row.updated_at) : null,
};
}
/** Map incoming camelCase body fields to snake_case column names. */
const FIELD_MAP: Record<string, string> = {
slug: "slug",
date: "race_date",
title: "title",
distanceKm: "distance_km",
status: "status",
officialUrl: "official_url",
coverImageUrl: "cover_image_url",
startTime: "start_time",
clusterSchedule: "cluster_schedule",
bibPickup: "bib_pickup",

View File

@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path";
import { pool } from "./db";
async function migrate() {
export async function migrate() {
console.log("[migrate] Running migrations…");
const migrationsDir = path.resolve(__dirname, "../migrations");
@@ -36,11 +36,14 @@ async function migrate() {
console.log("[migrate] Done.");
} finally {
client.release();
await pool.end();
}
}
migrate().catch((err) => {
console.error("[migrate] FAILED:", err.message);
process.exit(1);
});
if (require.main === module) {
migrate()
.catch((err) => {
console.error("[migrate] FAILED:", err.message);
process.exitCode = 1;
})
.finally(() => pool.end());
}

View File

@@ -0,0 +1,190 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const IMAGE_META_KEYS = new Set([
"og:image",
"og:image:url",
"twitter:image",
"twitter:image:src",
]);
const FETCH_TIMEOUT_MS = 5_000;
const MAX_REDIRECTS = 3;
const MAX_HTML_BYTES = 1_000_000;
function getAttribute(tag: string, name: string): string | null {
const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, "i");
return tag.match(pattern)?.[1] ?? null;
}
function toHttpUrl(value: string, baseUrl: string): string | null {
try {
const url = new URL(value, baseUrl);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
function isPrivateIp(address: string): boolean {
if (isIP(address) === 4) {
const [first, second] = address.split(".").map(Number);
return first === 0 || first === 10 || first === 127 || first >= 224 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && (second === 0 || second === 168)) ||
(first === 198 && (second === 18 || second === 19));
}
const normalized = address.toLowerCase();
return normalized === "::" || normalized === "::1" || normalized.startsWith("::ffff:") ||
/^f[cd]/.test(normalized) || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff");
}
async function isSafePublicHttpUrl(value: string): Promise<boolean> {
const normalized = toHttpUrl(value, value);
if (!normalized) {
return false;
}
const hostname = new URL(normalized).hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
return false;
}
if (isIP(hostname)) {
return !isPrivateIp(hostname);
}
try {
const addresses = await lookup(hostname, { all: true, verbatim: true });
return addresses.length > 0 && addresses.every(({ address }) => !isPrivateIp(address));
} catch {
return false;
}
}
async function readHtml(response: Response): Promise<string | null> {
const contentLength = response.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_HTML_BYTES) {
return null;
}
const reader = response.body?.getReader();
if (!reader) {
return "";
}
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
return new TextDecoder().decode(Buffer.concat(chunks));
}
size += value.byteLength;
if (size > MAX_HTML_BYTES) {
await reader.cancel();
return null;
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
}
function isRuncRunUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname.toLowerCase();
return hostname === "runc.run" || hostname.endsWith(".runc.run");
} catch {
return false;
}
}
function findRuncIntroImage(html: string, baseUrl: string): string | null {
const introMatch = html.match(/<div\b[^>]*class=["'][^"']*\brun-intro__image\b[^"']*["'][^>]*>[\s\S]*?<img\b[^>]*>/i);
if (!introMatch) {
return null;
}
const src = getAttribute(introMatch[0], "src");
return src ? toHttpUrl(src, baseUrl) : null;
}
function findMetaImage(html: string, baseUrl: string): string | null {
const tags = html.match(/<meta\b[^>]*>/gi) ?? [];
for (const tag of tags) {
const key = (getAttribute(tag, "property") || getAttribute(tag, "name") || "").toLowerCase();
if (!IMAGE_META_KEYS.has(key)) {
continue;
}
const content = getAttribute(tag, "content");
if (!content) {
continue;
}
const imageUrl = toHttpUrl(content, baseUrl);
if (imageUrl) {
return imageUrl;
}
}
return null;
}
export function extractRaceCoverImageFromHtml(html: string, pageUrl: string): string | null {
if (isRuncRunUrl(pageUrl)) {
const runcImage = findRuncIntroImage(html, pageUrl);
if (runcImage) {
return runcImage;
}
}
return findMetaImage(html, pageUrl);
}
export async function extractRaceCoverImage(officialUrl: string): Promise<string | null> {
const normalizedUrl = toHttpUrl(officialUrl, officialUrl);
if (!normalizedUrl) {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
let pageUrl = normalizedUrl;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
if (!(await isSafePublicHttpUrl(pageUrl))) {
return null;
}
const response = await fetch(pageUrl, { redirect: "manual", signal: controller.signal });
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
const nextUrl = location ? toHttpUrl(location, pageUrl) : null;
if (!nextUrl) {
return null;
}
pageUrl = nextUrl;
continue;
}
if (!response.ok) {
return null;
}
const html = await readHtml(response);
return html == null ? null : extractRaceCoverImageFromHtml(html, pageUrl);
}
return null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}

296
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,296 @@
import { Router, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import { z } from "zod";
import {
changePassword,
listActiveSessions,
loginUser,
registerUser,
requestPasswordReset,
resendVerification,
resetPassword,
revokeOtherSessions,
revokeSessionById,
rotateCsrf,
revokeSession,
updateProfile,
verifyEmailToken,
} from "../authService";
import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware";
import { isValidPassword, normalizeEmail } from "../security";
import { verifyTurnstileToken } from "../turnstile";
const router = Router();
const genericOk = { ok: true };
const genericAuthError = { error: "invalid_credentials", details: ["Invalid email or password"] };
const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false });
const loginLimiter = rateLimit({ windowMs: 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false });
const loginEmailLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `login:${normalizeEmail(String(req.body?.email ?? ""))}`,
});
const emailIpLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false });
const emailAddressLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `email:${normalizeEmail(String(req.body?.email ?? ""))}`,
});
const passwordChangeLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `password:${req.auth?.user.id ?? req.ip}`,
});
const registerSchema = z.object({
email: z.string().email(),
password: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
turnstileToken: z.string().min(1),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const tokenSchema = z.object({
token: z.string().min(16),
});
const emailSchema = z.object({
email: z.string().email(),
});
const resetSchema = z.object({
token: z.string().min(16),
password: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
});
const passwordChangeSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
}).strict();
const sessionIdSchema = z.string().uuid();
const profileUsernameSchema = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
const profileSchema = z.object({
profileUsername: profileUsernameSchema.nullable().optional(),
isFutureRacesPublic: z.boolean().optional(),
isCompletedRacesPublic: z.boolean().optional(),
}).strict().refine((profile) => Object.keys(profile).length > 0);
function validationError(res: Response): void {
res.status(400).json({ error: "validation_error", details: ["Invalid request body"] });
}
router.post("/auth/register", registerLimiter, async (req: Request, res: Response, next) => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const captchaOk = await verifyTurnstileToken(parsed.data.turnstileToken, req.ip);
if (!captchaOk) {
res.status(400).json({ error: "captcha_failed", details: ["Captcha verification failed"] });
return;
}
await registerUser(parsed.data.email, parsed.data.password);
res.status(202).json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/login", loginLimiter, loginEmailLimiter, async (req: Request, res: Response, next) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(401).json(genericAuthError);
return;
}
try {
const result = await loginUser(parsed.data.email, parsed.data.password);
if (!result.ok) {
res.status(401).json(genericAuthError);
return;
}
setSessionCookie(res, result.sessionToken);
res.json({ user: result.user, csrfToken: result.csrfToken });
} catch (error) {
next(error);
}
});
router.post("/auth/logout", async (req: Request, res: Response, next) => {
try {
if (req.auth) {
await revokeSession(req.auth.sessionToken);
}
clearSessionCookie(res);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get("/auth/me", async (req: Request, res: Response, next) => {
if (!req.auth) {
res.status(401).json({ error: "unauthorized", details: ["Authentication required"] });
return;
}
try {
const csrfToken = await rotateCsrf(req.auth.sessionToken);
res.json({ user: req.auth.user, csrfToken });
} catch (error) {
next(error);
}
});
router.patch("/auth/profile", requireAuth, async (req: Request, res: Response, next) => {
const parsed = profileSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
await updateProfile(req.auth!.user.id, parsed.data);
res.json({ ok: true });
} catch (error) {
if (typeof error === "object" && error !== null && (error as { code?: string }).code === "23505") {
res.status(409).json({ error: "username_taken", details: ["Username is already in use"] });
return;
}
next(error);
}
});
router.get("/auth/sessions", requireAuth, async (req: Request, res: Response, next) => {
try {
res.json({ sessions: await listActiveSessions(req.auth!.user.id, req.auth!.sessionToken) });
} catch (error) {
next(error);
}
});
router.post("/auth/password", requireAuth, passwordChangeLimiter, async (req: Request, res: Response, next) => {
const parsed = passwordChangeSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const changed = await changePassword(req.auth!.user.id, req.auth!.sessionToken, parsed.data.currentPassword, parsed.data.newPassword);
if (!changed) {
res.status(400).json({ error: "invalid_current_password", details: ["Current password is incorrect"] });
return;
}
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.delete("/auth/sessions/:id", requireAuth, async (req: Request, res: Response, next) => {
const parsed = sessionIdSchema.safeParse(req.params.id);
if (!parsed.success) {
validationError(res);
return;
}
try {
const result = await revokeSessionById(req.auth!.user.id, parsed.data, req.auth!.sessionToken);
if (!result.revoked) {
res.status(404).json({ error: "not_found", details: ["Session not found"] });
return;
}
if (result.current) {
clearSessionCookie(res);
}
res.status(204).end();
} catch (error) {
next(error);
}
});
router.post("/auth/sessions/revoke-others", requireAuth, async (req: Request, res: Response, next) => {
try {
await revokeOtherSessions(req.auth!.user.id, req.auth!.sessionToken);
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/verify-email", emailIpLimiter, async (req: Request, res: Response, next) => {
const parsed = tokenSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const ok = await verifyEmailToken(parsed.data.token);
if (!ok) {
res.status(410).json({ error: "invalid_token", details: ["Token is invalid or expired"] });
return;
}
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/resend-verification", emailIpLimiter, emailAddressLimiter, async (req: Request, res: Response, next) => {
const parsed = emailSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
await resendVerification(parsed.data.email);
res.status(202).json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/forgot-password", emailIpLimiter, emailAddressLimiter, async (req: Request, res: Response, next) => {
const parsed = emailSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
await requestPasswordReset(parsed.data.email);
res.status(202).json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/reset-password", emailIpLimiter, async (req: Request, res: Response, next) => {
const parsed = resetSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const ok = await resetPassword(parsed.data.token, parsed.data.password);
if (!ok) {
res.status(410).json({ error: "invalid_token", details: ["Token is invalid or expired"] });
return;
}
clearSessionCookie(res);
res.json(genericOk);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -1,10 +1,16 @@
import { Router, Request, Response } from "express";
import { checkDbConnection } from "../db";
import { getBackendVersion } from "../version";
const router = Router();
router.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok" });
res.json({ status: "ok", version: getBackendVersion() });
});
/** Версия для UI; путь без «health», чтобы реже резался фильтрами/прокси. */
router.get("/meta", (_req: Request, res: Response) => {
res.json({ version: getBackendVersion() });
});
router.get("/ready", async (_req: Request, res: Response) => {

View File

@@ -1,8 +1,12 @@
import { Router, Request, Response } from "express";
import { z } from "zod";
import { pool } from "../db";
import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
import { extractRaceCoverImage } from "../raceCoverImage";
import { requireAuth } from "../authMiddleware";
const router = Router();
router.use(requireAuth);
type ValidationErrorBody = {
error: "validation_error";
@@ -18,6 +22,87 @@ function validationError(res: Response, details: string[]) {
res.status(400).json(body);
}
const optionalText = (max: number) => z.string().trim().max(max).nullable().optional();
const httpUrl = z.string().trim().max(2048).url().refine(
(value) => {
try {
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
},
"Must be an HTTP(S) URL",
);
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD").refine((value) => {
const parsed = new Date(`${value}T00:00:00.000Z`);
return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value;
}, "Must be a valid calendar date");
const raceFields = {
slug: z.string().trim().min(1).max(120).regex(/^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u, "Must be a slug"),
date,
title: z.string().trim().min(1).max(200),
distanceKm: z.number().finite().gt(0).max(999.999),
status: z.enum(["planned", "registered", "completed"]).nullable(),
officialUrl: httpUrl.nullable(),
coverImageUrl: httpUrl.nullable(),
startTime: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/, "Must be HH:MM or HH:MM:SS").nullable(),
clusterSchedule: optionalText(1_000),
bibPickup: optionalText(500),
bibNumber: optionalText(100),
finishTime: z.string().regex(/^(?:[0-5]\d|[0-5]?\d:[0-5]\d|\d+:[0-5]\d:[0-5]\d)$/, "Must be SS, MM:SS or H:MM:SS").nullable(),
finishPlace: optionalText(100),
notes: optionalText(5_000),
};
const createRaceSchema = z.object({
slug: raceFields.slug,
date: raceFields.date,
title: raceFields.title,
distanceKm: raceFields.distanceKm,
status: raceFields.status.optional(),
officialUrl: raceFields.officialUrl.optional(),
coverImageUrl: raceFields.coverImageUrl.optional(),
startTime: raceFields.startTime.optional(),
clusterSchedule: raceFields.clusterSchedule,
bibPickup: raceFields.bibPickup,
bibNumber: raceFields.bibNumber,
finishTime: raceFields.finishTime.optional(),
finishPlace: raceFields.finishPlace,
notes: raceFields.notes,
}).strict();
const updateRaceSchema = z.object({
slug: raceFields.slug.optional(),
date: raceFields.date.optional(),
title: raceFields.title.optional(),
distanceKm: raceFields.distanceKm.optional(),
status: raceFields.status.optional(),
officialUrl: raceFields.officialUrl.optional(),
coverImageUrl: raceFields.coverImageUrl.optional(),
startTime: raceFields.startTime.optional(),
clusterSchedule: raceFields.clusterSchedule,
bibPickup: raceFields.bibPickup,
bibNumber: raceFields.bibNumber,
finishTime: raceFields.finishTime.optional(),
finishPlace: raceFields.finishPlace,
notes: raceFields.notes,
}).strict().refine((body) => Object.keys(body).length > 0, "No updatable fields provided");
function schemaDetails(error: z.ZodError): string[] {
return error.issues.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`);
}
function raceIdFromRequest(req: Request, res: Response): string | null {
const parsed = z.string().uuid().safeParse(req.params.id);
if (parsed.success) {
return parsed.data;
}
validationError(res, ["id: Must be a UUID"]);
return null;
}
function parseOptionalIntegerQuery(
value: unknown,
fieldName: string,
@@ -68,6 +153,9 @@ router.get("/races", async (req: Request, res: Response) => {
const params: unknown[] = [];
let idx = 1;
conditions.push(`owner_user_id = $${idx++}`);
params.push(req.auth!.user.id);
if (yearResult.value != null) {
conditions.push(`EXTRACT(YEAR FROM race_date) = $${idx++}`);
params.push(yearResult.value);
@@ -91,10 +179,14 @@ router.get("/races", async (req: Request, res: Response) => {
/* ─── GET /races/:id ──────────────────────────────────────── */
router.get("/races/:id", async (req: Request, res: Response) => {
const id = raceIdFromRequest(req, res);
if (!id) {
return;
}
try {
const { rows } = await pool.query<RaceRow>(
"SELECT * FROM races WHERE id = $1",
[req.params.id],
"SELECT * FROM races WHERE id = $1 AND owner_user_id = $2",
[id, req.auth!.user.id],
);
if (rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });
@@ -110,17 +202,24 @@ router.get("/races/:id", async (req: Request, res: Response) => {
/* ─── POST /races ─────────────────────────────────────────── */
router.post("/races", async (req: Request, res: Response) => {
const body = req.body;
if (!body.id || !body.date || !body.title || body.distanceKm == null) {
validationError(res, ["Fields id, date, title, distanceKm are required"]);
const parsed = createRaceSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res, schemaDetails(parsed.error));
return;
}
const { columns, values } = bodyToColumns(body);
columns.unshift("id");
values.unshift(body.id);
const payload = { ...parsed.data };
const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "";
const officialUrl = payload.officialUrl;
const hasOfficialUrl = typeof officialUrl === "string" && officialUrl.trim() !== "";
if (!hasManualCover && hasOfficialUrl) {
payload.coverImageUrl = await extractRaceCoverImage(officialUrl);
}
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
columns.unshift("owner_user_id");
values.unshift(req.auth!.user.id);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
@@ -131,7 +230,7 @@ router.post("/races", async (req: Request, res: Response) => {
if (err.code === "23505") {
res.status(409).json({
error: "conflict",
details: ["Race with this id already exists"],
details: ["Race with this slug already exists"],
});
return;
}
@@ -143,17 +242,22 @@ router.post("/races", async (req: Request, res: Response) => {
/* ─── PATCH /races/:id ────────────────────────────────────── */
router.patch("/races/:id", async (req: Request, res: Response) => {
const { columns, values } = bodyToColumns(req.body);
if (columns.length === 0) {
validationError(res, ["No updatable fields provided"]);
const id = raceIdFromRequest(req, res);
if (!id) {
return;
}
const parsed = updateRaceSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res, schemaDetails(parsed.error));
return;
}
const { columns, values } = bodyToColumns(parsed.data as Record<string, unknown>);
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`);
values.push(req.params.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length} RETURNING *`;
values.push(id);
values.push(req.auth!.user.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`;
try {
const { rows } = await pool.query<RaceRow>(sql, values);
@@ -171,10 +275,14 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
/* ─── DELETE /races/:id ───────────────────────────────────── */
router.delete("/races/:id", async (req: Request, res: Response) => {
const id = raceIdFromRequest(req, res);
if (!id) {
return;
}
try {
const { rowCount } = await pool.query(
"DELETE FROM races WHERE id = $1",
[req.params.id],
"DELETE FROM races WHERE id = $1 AND owner_user_id = $2",
[id, req.auth!.user.id],
);
if (rowCount === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });

View File

@@ -0,0 +1,63 @@
import { Request, Response, Router } from "express";
import { z } from "zod";
import { pool } from "../db";
const router: Router = Router();
interface PublicRaceRow {
race_date: string | Date;
title: string;
distance_km: string;
status: "planned" | "registered" | "completed" | null;
cover_image_url: string | null;
finish_time: string | null;
finish_place: string | null;
}
function raceDate(value: string | Date): string {
return typeof value === "string" ? value.slice(0, 10) : value.toISOString().slice(0, 10);
}
router.get("/users/:username/races", async (req: Request, res: Response) => {
const parsed = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/).safeParse(req.params.username);
if (!parsed.success) {
res.status(400).json({ error: "validation_error", details: ["username: Invalid username"] });
return;
}
try {
const profile = await pool.query(
`SELECT id, is_future_races_public, is_completed_races_public
FROM users
WHERE LOWER(profile_username) = $1
AND (is_future_races_public = TRUE OR is_completed_races_public = TRUE)`,
[parsed.data],
);
if (profile.rowCount === 0) {
res.status(404).json({ error: "not_found", details: ["Profile not found"] });
return;
}
const { rows } = await pool.query<PublicRaceRow>(
`SELECT race_date, title, distance_km, status, cover_image_url, finish_time, finish_place
FROM races
WHERE owner_user_id = $1
AND ((status = 'completed' AND $2) OR (status IS DISTINCT FROM 'completed' AND $3))
ORDER BY race_date ASC`,
[profile.rows[0].id, profile.rows[0].is_completed_races_public, profile.rows[0].is_future_races_public],
);
res.json(rows.map((row) => ({
date: raceDate(row.race_date),
title: row.title,
distanceKm: Number(row.distance_km),
status: row.status,
coverImageUrl: row.cover_image_url,
finishTime: row.status === "completed" ? row.finish_time : null,
finishPlace: row.status === "completed" ? row.finish_place : null,
})));
} catch (error) {
console.error("[GET /users/:username/races]", error);
res.status(503).json({ error: "database_unavailable" });
}
});
export default router;

63
backend/src/security.ts Normal file
View File

@@ -0,0 +1,63 @@
import crypto from "crypto";
import argon2 from "argon2";
export const PASSWORD_MIN_LENGTH = 15;
export const PASSWORD_MAX_LENGTH = 256;
export const ARGON2_OPTIONS: argon2.Options & { raw?: false } = {
type: argon2.argon2id,
memoryCost: 65_536,
timeCost: 3,
parallelism: 1,
hashLength: 32,
};
const DUMMY_PASSWORD_HASH =
"$argon2id$v=19$m=65536,t=3,p=1$Jkdr1qT0c9cPK5v8m0tEMQ$SLnLmyorTDzBK74I1lrEF92E7S0c6DAm8iMG0YOyAIo";
export function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
export function isValidPassword(value: string): boolean {
return value.length >= PASSWORD_MIN_LENGTH && value.length <= PASSWORD_MAX_LENGTH;
}
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(hash: string | null, password: string): Promise<boolean> {
const candidate = hash ?? DUMMY_PASSWORD_HASH;
try {
const ok = await argon2.verify(candidate, password);
return hash ? ok : false;
} catch {
return false;
}
}
export function randomToken(bytes = 32): string {
return crypto.randomBytes(bytes).toString("base64url");
}
export function sha256Hex(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
export function timingSafeEqualHex(left: string, right: string): boolean {
const a = Buffer.from(left, "hex");
const b = Buffer.from(right, "hex");
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
export function addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
export function addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
}

View File

@@ -0,0 +1,9 @@
import { sha256Hex } from "./security";
export function anonymizeEmail(email: string): string {
return sha256Hex(email).slice(0, 16);
}
export function securityLog(event: string, details: Record<string, unknown> = {}): void {
console.info("[security]", event, details);
}

View File

@@ -24,10 +24,49 @@ function makeId(date: string, title: string): string {
return `${date}-${slugify(title)}`;
}
async function resolveSeedOwnerUserId(client: { query: typeof pool.query }): Promise<string | null> {
const explicit = process.env.SEED_OWNER_USER_ID?.trim();
if (explicit) {
return explicit;
}
const email = process.env.SEED_OWNER_EMAIL?.trim().toLowerCase();
if (email) {
const { rows } = await client.query<{ id: string }>(
"SELECT id FROM users WHERE LOWER(BTRIM(email)) = $1",
[email],
);
if (rows[0]) {
return rows[0].id;
}
throw new Error("SEED_OWNER_EMAIL does not match an existing user");
}
const { rows } = await client.query<{ count: string }>("SELECT COUNT(*)::text AS count FROM users");
const usersCount = Number(rows[0]?.count ?? "0");
if (usersCount > 0) {
throw new Error("Refusing to seed without SEED_OWNER_USER_ID or SEED_OWNER_EMAIL after users exist");
}
return null;
}
const CSV_NAME = "races_2026_calendar.csv";
function resolveCsvPath(): string | null {
// Docker image: /app/dist/*.js → ../import = /app/import (matches Dockerfile COPY import ./import)
// Local monorepo: backend/dist/*.js → ../../import = repo root import/
const candidates = [
path.resolve(__dirname, "../import", CSV_NAME),
path.resolve(__dirname, "../../import", CSV_NAME),
];
return candidates.find((p) => fs.existsSync(p)) ?? null;
}
async function seed() {
const csvPath = path.resolve(__dirname, "../../import/races_2026_calendar.csv");
if (!fs.existsSync(csvPath)) {
console.error(`[seed] CSV not found: ${csvPath}`);
const csvPath = resolveCsvPath();
if (!csvPath) {
console.error(
`[seed] CSV not found: ${CSV_NAME}. Tried:\n - ${path.resolve(__dirname, "../import", CSV_NAME)}\n - ${path.resolve(__dirname, "../../import", CSV_NAME)}`,
);
process.exit(1);
}
@@ -42,23 +81,28 @@ async function seed() {
const client = await pool.connect();
try {
const ownerUserId = await resolveSeedOwnerUserId(client);
for (const row of records) {
const id = makeId(row.date, row.event);
const slug = makeId(row.date, row.event);
const distanceKm = parseFloat(row.distance_km);
await client.query(
`INSERT INTO races (id, race_date, title, distance_km, status)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
race_date = EXCLUDED.race_date,
title = EXCLUDED.title,
distance_km = EXCLUDED.distance_km,
status = EXCLUDED.status,
updated_at = NOW()`,
[id, row.date, row.event, distanceKm, "planned"],
);
if (ownerUserId) {
await client.query(
`INSERT INTO races (slug, owner_user_id, race_date, title, distance_km, status, source)
VALUES ($1, $2, $3, $4, $5, $6, 'seed')
ON CONFLICT DO NOTHING`,
[slug, ownerUserId, row.date, row.event, distanceKm, "planned"],
);
} else {
await client.query(
`INSERT INTO races (slug, race_date, title, distance_km, status, source)
VALUES ($1, $2, $3, $4, $5, 'seed')
ON CONFLICT DO NOTHING`,
[slug, row.date, row.event, distanceKm, "planned"],
);
}
console.log(`[seed] Upserted: ${id}`);
console.log(`[seed] Inserted if missing: ${slug}`);
}
console.log("[seed] Done.");

33
backend/src/turnstile.ts Normal file
View File

@@ -0,0 +1,33 @@
import { config } from "./config";
export async function verifyTurnstileToken(token: string, remoteIp?: string): Promise<boolean> {
if (config.turnstile.bypassToken && token === config.turnstile.bypassToken) {
return true;
}
if (!config.turnstile.secretKey) {
return false;
}
const body = new URLSearchParams();
body.set("secret", config.turnstile.secretKey);
body.set("response", token);
if (remoteIp) {
body.set("remoteip", remoteIp);
}
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return false;
}
const payload = (await response.json()) as { success?: boolean };
return payload.success === true;
} catch {
return false;
}
}

24
backend/src/version.ts Normal file
View File

@@ -0,0 +1,24 @@
import fs from "fs";
import path from "path";
let cached: string | null = null;
export function getBackendVersion(): string {
if (cached) {
return cached;
}
const fromEnv = process.env.APP_VERSION?.trim();
if (fromEnv) {
cached = fromEnv;
return cached;
}
try {
const pkgPath = path.join(__dirname, "..", "package.json");
const raw = fs.readFileSync(pkgPath, "utf-8");
cached = (JSON.parse(raw) as { version: string }).version;
return cached;
} catch {
cached = "0.0.0";
return cached;
}
}

View File

@@ -1,39 +1,654 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import fs from "node:fs";
import path from "node:path";
import { after, test } from "node:test";
import request from "supertest";
import { createApp } from "../src/app";
import { buildHelmetOptions, createApp } from "../src/app";
import {
cleanupExpiredAuthRows,
createResetToken,
createVerificationToken,
resetPassword,
verifyEmailToken,
} from "../src/authService";
import { resolveTurnstileBypassToken } from "../src/config";
import { pool } from "../src/db";
import { extractRaceCoverImage, extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
import { hashPassword, normalizeEmail } from "../src/security";
const app = createApp();
let userCounter = 0;
test("GET /health returns ok", async () => {
const res = await request(app).get("/health").expect(200);
after(() => pool.end());
async function authAgent() {
userCounter += 1;
const email = normalizeEmail(`runner${userCounter}@example.com`);
const password = "correct horse battery staple";
const passwordHash = await hashPassword(password);
const inserted = await pool.query<{ id: string }>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id`,
[email, passwordHash],
);
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
inserted.rows[0].id,
]);
const agent = request.agent(app);
const login = await agent.post("/api/auth/login").send({ email, password }).expect(200);
return { agent, csrfToken: login.body.csrfToken as string };
}
async function createVerifiedUser(email: string, password: string) {
return createUser(email, password, true);
}
async function createUnverifiedUser(email: string, password: string) {
return createUser(email, password, false);
}
async function createUser(email: string, password: string, verified: boolean) {
const passwordHash = await hashPassword(password);
const inserted = await pool.query<{ id: string }>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id`,
[normalizeEmail(email), passwordHash],
);
if (verified) {
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
inserted.rows[0].id,
]);
}
return inserted.rows[0].id;
}
async function loginAgent(email: string, password: string) {
const agent = request.agent(app);
const login = await agent.post("/api/auth/login").send({ email, password }).expect(200);
return { agent, csrfToken: login.body.csrfToken as string };
}
async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) {
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`,
[tokenHash],
);
return Number(rows[0]?.count ?? "0");
}
test("GET /api/health returns ok", async () => {
const res = await request(app).get("/api/health").expect(200);
assert.equal(res.body.status, "ok");
assert.equal(typeof res.body.version, "string");
assert.ok(res.body.version.length > 0);
});
test("GET /ready succeeds with mock database", async () => {
const res = await request(app).get("/ready").expect(200);
test("GET /api/meta returns version for UI footer", async () => {
const res = await request(app).get("/api/meta").expect(200);
assert.equal(typeof res.body.version, "string");
assert.ok(res.body.version.length > 0);
});
test("GET /api/ready succeeds with PostgreSQL", async () => {
const res = await request(app).get("/api/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);
test("production config rejects Turnstile bypass token", () => {
assert.throws(
() =>
resolveTurnstileBypassToken({
rawBypassToken: "unsafe-bypass",
securityProfile: "production",
}),
/TURNSTILE_BYPASS_TOKEN/,
);
assert.equal(
resolveTurnstileBypassToken({
rawBypassToken: "dev-bypass",
securityProfile: "development",
}),
"dev-bypass",
);
});
test("production CSP allows Turnstile without unsafe script directives", () => {
const options = buildHelmetOptions("production") as any;
const directives = options.contentSecurityPolicy.directives;
assert.ok(directives.scriptSrc.includes("https://challenges.cloudflare.com"));
assert.ok(directives.frameSrc.includes("https://challenges.cloudflare.com"));
assert.ok(directives.connectSrc.includes("https://challenges.cloudflare.com"));
assert.ok(!directives.scriptSrc.includes("'unsafe-inline'"));
assert.ok(!directives.scriptSrc.includes("'unsafe-eval'"));
assert.deepEqual(directives.frameAncestors, ["'none'"]);
assert.deepEqual(directives.objectSrc, ["'none'"]);
});
test("duplicate registration keeps generic accepted response", async () => {
const payload = {
email: "duplicate-register@example.com",
password: "correct horse battery staple",
turnstileToken: "mock-turnstile-token",
};
const first = await request(app).post("/api/auth/register").send(payload).expect(202);
const second = await request(app).post("/api/auth/register").send(payload).expect(202);
assert.deepEqual(first.body, { ok: true });
assert.deepEqual(second.body, { ok: true });
});
test("GET /api/races rejects invalid year", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/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);
test("GET /api/races rejects month out of range", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/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);
test("GET /api/races accepts year and month", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/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");
test("GET /api/races/:id rejects a non-UUID id", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/races/does-not-exist").expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(Array.isArray(res.body.details));
});
test("GET /api/races requires authentication", async () => {
const res = await request(app).get("/api/races").expect(401);
assert.equal(res.body.error, "unauthorized");
});
test("public profile uses its username and separate visibility settings", async () => {
const { agent } = await authAgent();
const user = await agent.get("/api/auth/me").expect(200);
await agent
.post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({
slug: "2026-08-01-planned-race",
date: "2026-08-01",
title: "Planned Race",
distanceKm: 10,
})
.expect(201);
await agent
.post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({
slug: "2026-07-01-completed-race",
date: "2026-07-01",
title: "Completed Race",
distanceKm: 10,
status: "completed",
finishTime: "00:40:00",
finishPlace: "12",
notes: "Private note",
})
.expect(201);
await request(app).get("/api/users/public-runner/races").expect(404);
await agent
.patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({ profileUsername: "public-runner", isFutureRacesPublic: true, isCompletedRacesPublic: false })
.expect(200);
const futureRaces = await request(app).get("/api/users/public-runner/races").expect(200);
assert.deepEqual(futureRaces.body, [{
date: "2026-08-01",
title: "Planned Race",
distanceKm: 10,
status: null,
coverImageUrl: null,
finishTime: null,
finishPlace: null,
}]);
await agent
.patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({ isFutureRacesPublic: false, isCompletedRacesPublic: true })
.expect(200);
const completedRaces = await request(app).get("/api/users/public-runner/races").expect(200);
assert.deepEqual(completedRaces.body, [{
date: "2026-07-01",
title: "Completed Race",
distanceKm: 10,
status: "completed",
coverImageUrl: null,
finishTime: "00:40:00",
finishPlace: "12",
}]);
});
test("login uses generic response for missing user and wrong password", async () => {
const password = "correct horse battery staple";
await createVerifiedUser("generic@example.com", password);
const wrongPassword = await request(app)
.post("/api/auth/login")
.send({ email: "generic@example.com", password: "wrong password" })
.expect(401);
const missingUser = await request(app)
.post("/api/auth/login")
.send({ email: "missing@example.com", password })
.expect(401);
assert.deepEqual(missingUser.body, wrongPassword.body);
});
test("account session endpoints list and revoke only the current user's sessions", async () => {
userCounter += 1;
const email = `sessions${userCounter}@example.com`;
const password = "correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
const listed = await first.agent.get("/api/auth/sessions").expect(200);
assert.equal(listed.body.sessions.length, 2);
assert.ok(listed.body.sessions.every((session: { tokenHash?: unknown }) => session.tokenHash === undefined));
const other = listed.body.sessions.find((session: { current: boolean }) => !session.current);
assert.ok(other);
const outsider = await authAgent();
const foreign = (await outsider.agent.get("/api/auth/sessions").expect(200)).body.sessions[0];
await first.agent
.delete(`/api/auth/sessions/${foreign.id}`)
.set("X-CSRF-Token", first.csrfToken)
.expect(404);
await first.agent
.delete(`/api/auth/sessions/${other.id}`)
.set("X-CSRF-Token", first.csrfToken)
.expect(204);
await second.agent.get("/api/auth/me").expect(401);
await first.agent.get("/api/auth/sessions").expect(200).then((res) => assert.equal(res.body.sessions.length, 1));
});
test("password change checks the current password and revokes other sessions", async () => {
userCounter += 1;
const email = `password${userCounter}@example.com`;
const password = "correct horse battery staple";
const nextPassword = "another correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
await first.agent
.post("/api/auth/password")
.set("X-CSRF-Token", first.csrfToken)
.send({ currentPassword: "wrong password", newPassword: nextPassword })
.expect(400);
await first.agent
.post("/api/auth/password")
.set("X-CSRF-Token", first.csrfToken)
.send({ currentPassword: password, newPassword: nextPassword })
.expect(200);
await second.agent.get("/api/auth/me").expect(401);
await request(app).post("/api/auth/login").send({ email, password }).expect(401);
await request(app).post("/api/auth/login").send({ email, password: nextPassword }).expect(200);
});
test("revoke other sessions retains the current session", async () => {
userCounter += 1;
const email = `revoke-others${userCounter}@example.com`;
const password = "correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
await first.agent
.post("/api/auth/sessions/revoke-others")
.set("X-CSRF-Token", first.csrfToken)
.expect(200);
await first.agent.get("/api/auth/me").expect(200);
await second.agent.get("/api/auth/me").expect(401);
});
test("GET /api/races/:id returns not_found for another user's race", async () => {
const first = await authAgent();
const created = await first.agent
.post("/api/races")
.set("X-CSRF-Token", first.csrfToken)
.send({
slug: "2026-07-01-private-race",
date: "2026-07-01",
title: "Private Race",
distanceKm: 10,
})
.expect(201);
const second = await authAgent();
const res = await second.agent.get(`/api/races/${created.body.id}`).expect(404);
assert.equal(res.body.error, "not_found");
});
test("new password reset token invalidates previous token", async () => {
const userId = await createVerifiedUser("reset@example.com", "correct horse battery staple");
const client = await pool.connect();
const first = await createResetToken(client, userId);
const second = await createResetToken(client, userId);
client.release();
assert.equal(await resetPassword(first, "new correct horse battery staple"), false);
assert.equal(await resetPassword(second, "new correct horse battery staple"), true);
assert.equal(await resetPassword(second, "new correct horse battery staple"), false);
});
test("email verification token is single use", async () => {
const userId = await createUnverifiedUser("verify-once@example.com", "correct horse battery staple");
const client = await pool.connect();
const token = await createVerificationToken(client, userId);
client.release();
assert.equal(await verifyEmailToken(token), true);
assert.equal(await verifyEmailToken(token), false);
});
test("auth cleanup removes expired rows and keeps active rows", async () => {
const userId = await createVerifiedUser("cleanup@example.com", "correct horse battery staple");
const past = new Date(Date.now() - 60_000);
const future = new Date(Date.now() + 60_000);
const expiredSession = "expired-session-token-hash";
const activeSession = "active-session-token-hash";
const expiredVerify = "expired-verify-token-hash";
const activeVerify = "active-verify-token-hash";
const expiredReset = "expired-reset-token-hash";
const activeReset = "active-reset-token-hash";
await pool.query(
"INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)",
[userId, expiredSession, "expired-csrf", past],
);
await pool.query(
"INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)",
[userId, activeSession, "active-csrf", future],
);
await pool.query(
"INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, expiredVerify, past],
);
await pool.query(
"INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, activeVerify, future],
);
await pool.query(
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, expiredReset, past],
);
await pool.query(
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, activeReset, future],
);
await cleanupExpiredAuthRows();
assert.equal(await countByTokenHash("sessions", expiredSession), 0);
assert.equal(await countByTokenHash("sessions", activeSession), 1);
assert.equal(await countByTokenHash("email_verification_tokens", expiredVerify), 0);
assert.equal(await countByTokenHash("email_verification_tokens", activeVerify), 1);
assert.equal(await countByTokenHash("password_reset_tokens", expiredReset), 0);
assert.equal(await countByTokenHash("password_reset_tokens", activeReset), 1);
});
test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => {
const html = `
<meta property="og:image" content="https://example.com/og.jpg">
<div class="run-intro__image">
<div class="run-intro__image-left-shadow"></div>
<img src="/uploads/race_landing_header_backgrounds/header.jpg" alt="">
<div class="run-intro__image-right-shadow"></div>
</div>
`;
assert.equal(
extractRaceCoverImageFromHtml(html, "https://aprilrun5km.runc.run/"),
"https://aprilrun5km.runc.run/uploads/race_landing_header_backgrounds/header.jpg",
);
});
test("extractRaceCoverImageFromHtml reads Open Graph and Twitter images", () => {
assert.equal(
extractRaceCoverImageFromHtml(
'<meta property="og:image" content="/cover.png">',
"https://example.com/race",
),
"https://example.com/cover.png",
);
assert.equal(
extractRaceCoverImageFromHtml(
'<meta name="twitter:image" content="https://cdn.example.com/twitter.jpg">',
"https://example.com/race",
),
"https://cdn.example.com/twitter.jpg",
);
});
test("POST /api/races stores manual coverImageUrl", async () => {
const { agent, csrfToken } = await authAgent();
const coverImageUrl = "https://example.com/manual.jpg";
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-01-manual-cover",
date: "2026-06-01",
title: "Manual Cover",
distanceKm: 10,
officialUrl: "https://example.com/race",
coverImageUrl,
})
.expect(201);
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("POST /api/races rejects unknown and malformed fields", async () => {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "invalid-race",
date: "2026-02-30",
title: "Invalid Race",
distanceKm: 10,
startTime: "24:00",
unexpected: true,
})
.expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(res.body.details.some((detail: string) => detail.includes("date")));
assert.ok(res.body.details.some((detail: string) => detail.includes("startTime")));
assert.ok(res.body.details.some((detail: string) => detail.includes("Unrecognized")));
});
test("POST /api/races accepts a seconds-only finish time", async () => {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-01-seconds-only",
date: "2026-06-01",
title: "Seconds Only",
distanceKm: 1,
finishTime: "05",
})
.expect(201);
assert.equal(res.body.finishTime, "05");
});
test("POST /api/races auto extracts coverImageUrl from officialUrl", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response('<meta property="og:image" content="/auto.jpg">', {
status: 200,
headers: { "content-type": "text/html" },
});
try {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-02-auto-cover",
date: "2026-06-02",
title: "Auto Cover",
distanceKm: 21.1,
officialUrl: "https://example.com/race",
})
.expect(201);
assert.equal(res.body.coverImageUrl, "https://example.com/auto.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/races succeeds when cover extraction fails", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error("network down");
};
try {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-03-cover-fail",
date: "2026-06-03",
title: "Cover Fail",
distanceKm: 5,
officialUrl: "https://example.com/race",
})
.expect(201);
assert.equal(res.body.coverImageUrl, null);
} finally {
globalThis.fetch = originalFetch;
}
});
test("cover extraction never fetches loopback URLs", async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response("");
};
try {
assert.equal(await extractRaceCoverImage("http://127.0.0.1/private"), null);
assert.equal(calls, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test("cover extraction rejects a redirect to a private URL", async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response(null, { status: 302, headers: { location: "http://127.0.0.1/private" } });
};
try {
assert.equal(await extractRaceCoverImage("https://8.8.8.8/race"), null);
assert.equal(calls, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
const { agent, csrfToken } = await authAgent();
const created = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-04-patch-cover",
date: "2026-06-04",
title: "Patch Cover",
distanceKm: 10,
})
.expect(201);
const coverImageUrl = "https://example.com/patched.jpg";
const res = await agent
.patch(`/api/races/${created.body.id}`)
.set("X-CSRF-Token", csrfToken)
.send({ coverImageUrl })
.expect(200);
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("004 migrates legacy text race ids to UUIDs", async () => {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("DROP TABLE races");
await client.query(`
CREATE TABLE races (
id TEXT PRIMARY KEY,
race_date DATE NOT NULL,
title TEXT NOT NULL,
distance_km NUMERIC(6,3) NOT NULL,
status TEXT CHECK (status IS NULL OR status IN ('planned', 'registered', 'completed')),
official_url TEXT,
start_time TEXT,
cluster_schedule TEXT,
bib_pickup TEXT,
bib_number TEXT,
finish_time TEXT,
notes TEXT,
finish_place TEXT,
cover_image_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
)
`);
await client.query(
"INSERT INTO races (id, race_date, title, distance_km) VALUES ($1, $2, $3, $4)",
["legacy-race", "2026-01-01", "Legacy Race", 10],
);
const migration = fs.readFileSync(path.resolve(__dirname, "../migrations/004_auth_and_race_ownership.sql"), "utf8");
await client.query(migration);
const { rows } = await client.query<{ id: string; slug: string; source: string }>(
"SELECT id, slug, source FROM races WHERE slug = $1",
["legacy-race"],
);
assert.equal(rows[0]?.slug, "legacy-race");
assert.equal(rows[0]?.source, "user");
assert.match(rows[0]?.id ?? "", /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
} finally {
await client.query("ROLLBACK");
client.release();
}
});

21
backend/test/setup.ts Normal file
View File

@@ -0,0 +1,21 @@
process.env.NODE_ENV ??= "test";
process.env.SECURITY_PROFILE ??= "test";
process.env.SESSION_SECRET ??= "test-session-secret";
process.env.TURNSTILE_BYPASS_TOKEN ??= "mock-turnstile-token";
process.env.DB_HOST ??= "127.0.0.1";
process.env.DB_PORT ??= "5432";
process.env.DB_NAME ??= "calendar_run_test";
process.env.DB_USER ??= "postgres";
process.env.DB_PASSWORD ??= "postgres";
if (process.env.DB_NAME !== "calendar_run_test") {
throw new Error("Tests require DB_NAME=calendar_run_test");
}
const [{ migrate }, { pool }] = await Promise.all([
import("../src/migrate"),
import("../src/db"),
]);
await migrate();
await pool.query("TRUNCATE app_settings, races, users RESTART IDENTITY CASCADE");

53
docker-compose.stack.yml Normal file
View File

@@ -0,0 +1,53 @@
# Backend + frontend в сети с уже поднятым PostgreSQL (external network).
#
# Подготовка: из корня репозитория скопируйте .env.example → .env и задайте DB_*
# и CORS_ORIGIN (для фронта на :3033 — http://localhost:3033).
#
# Сеть (имя должно существовать, как у вашего Postgres):
# docker network ls
#
# Запуск:
# docker compose -f docker-compose.stack.yml up -d --build
#
# Миграции и seed (один раз после появления БД):
# docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/migrate.js
# docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/seed.js
#
# NPM: проброс на порт 3033. Браузер ходит на /api → nginx во фронте → runners-calendar-backend:3000.
# Имя сервиса уникально в общей сети (не «backend»), чтобы не пересекаться с другими стеками.
services:
runners-calendar-backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: runners-calendar-backend
env_file:
- .env
environment:
- PORT=3000
ports:
- "3001:3000"
volumes:
# CSV и прочие данные import/ на хосте (Synology: ./import рядом с compose) без пересборки образа
- ./import:/app/import:ro
restart: unless-stopped
networks:
- postgres_default
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
container_name: runners-calendar-frontend
depends_on:
- runners-calendar-backend
ports:
- "3033:80"
restart: unless-stopped
networks:
- postgres_default
networks:
postgres_default:
external: true

View File

@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Браузер ходит на тот же origin: /api/* → бэкенд с тем же префиксом /api
location /api/ {
proxy_pass http://runners-calendar-backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -1,162 +0,0 @@
# Инструкция агенту: реализация бэкенда по [PLAN.md](../PLAN.md)
Документ для ИИ-агента или разработчика, который создаёт **backend** монорепозитория «календарь забегов». Продуктовые цели и модель данных — в корневом **PLAN.md**; здесь — порядок работ, ограничения и **обязательные итоговые артефакты для фронтенда**.
---
## 0. Ограничение: нет возможности проверить подключение к БД
1. **Всё равно реализовать полноценный бэкенд «как для прод»**: миграции SQL, пул подключений `pg`, переменные `DB_*`, `docker-compose.yml` с PostgreSQL в корне репозитория.
2. **Не блокировать работу отсутствием живой БД у исполнителя:**
- код миграций и seed должен быть **валидным и согласованным** с PLAN;
- при старте API при невозможности подключиться к БД — **ясное сообщение в лог** и **корректный HTTP-ответ** на зависящих от БД маршрутах (например 503 с телом `{"error":"database_unavailable",...}`) **или** падение процесса на старте с понятной ошибкой (выбрать одну стратегию и описать её в `docs/backend.md`).
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 кода.
---
## 1. Ветка и расположение в репо
- Если репозиторий под git: создать ветку `feature/backend-api` (или аналог по соглашению команды).
- Каталог **`backend/`** в корне проекта рядом с будущим `frontend/`.
- Корневой **`docker-compose.yml`** — сервис `postgres` (порт, пользователь, БД, пароль — согласовать с `.env.example`).
---
## 2. Порядок реализации (обязательный)
### Шаг A. Каркас
- Node **LTS**, **TypeScript**, **`backend/package.json`**.
- Фреймворк: **Fastify** или **Express** (выбрать один, не смешивать).
- Загрузка env: `dotenv` или встроенные средства; **валидация** наличия `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` при старте (или при первом запросе к БД — но тогда задокументировать).
- Сервер слушает порт из **`PORT`** или **`API_PORT`** (значение по умолчанию, напр. `3001`, указать в `.env.example`).
### Шаг B. CORS
- Читать **`CORS_ORIGIN`** (например `http://localhost:5173` для Vite в dev). В prod — origin фронта.
- Разрешить методы и заголовки, нужные для `GET/POST/PATCH` + `Content-Type: application/json`.
### Шаг C. Миграции
- Каталог миграций, напр. `backend/migrations/` с нумерованными SQL-файлами **или** один миграционный runner (node-pg-migrate, graphile-migrate, собственный скрипт — на выбор, зафиксировать в `docs/backend.md`).
- Первая миграция: таблица **`races`** со столбцами, соответствующими PLAN (см. раздел 3 ниже).
- Команда **`npm run db:migrate`** (или эквивалент в `backend/`) — идемпотентное накатывание на чистую БД.
### Шаг D. Доступ к данным
- Клиент **`pg`**: `Pool` с параметрами из `DB_*`.
- Слой репозитория или прямые запросы в обработчиках — на усмотрение, без лишних абстракций сверх задачи.
### Шаг E. HTTP API
Реализовать минимум:
| Метод | Путь | Назначение |
|--------|------|------------|
| `GET` | `/health` | Liveness: процесс жив; **не обязан** проверять БД (или опционально — задокументировать). |
| `GET` | `/ready` (опционально) | Readiness: проверка соединения с БД — полезно для оркестраторов. |
| `GET` | `/races` | Список забегов; **query**: `year`, `month` (112) — фильтрация для экранов календаря; без параметров — все строки или разумный лимит + документация пагинации если добавите позже. |
| `GET` | `/races/:id` | Одна запись по `id`. |
| `POST` | `/races` | Создание; тело JSON в **camelCase** как в PLAN. |
| `PATCH` | `/races/:id` | Частичное обновление; только переданные поля. |
| `DELETE` | `/races/:id` | Опционально по PLAN; если не нужен фронту — можно не делать, но тогда явно написать в документации «удаление не поддерживается». |
**Ошибки:**
- 400 — валидация тела/параметров.
- 404 — нет `id`.
- 409 — конфликт уникального `id` при POST (если клиент прислал уже существующий).
- 503 или 500 — недоступна БД (согласовать с разделом 0).
Формат ошибки: JSON, единообразно, напр. `{"error":"validation_error","details":[...]}`.
### Шаг F. Seed (разовый скрипт)
- Команда **`npm run seed`** в `backend/` (или корне монорепо с `workspace` — единообразно).
- Читает **`import/races_2026_calendar.csv`** (путь от корня репо); парсинг с учётом кавычек в поле названия.
- Генерирует **`id`** стабильно: например `{date}-{slug-from-title}`; при коллизии — суффикс или upsert по `id`.
- **`INSERT ... ON CONFLICT (id) DO UPDATE`** (upsert) — удобно для повторного запуска seed.
- Seed **не** вызывается из HTTP handlers.
### Шаг G. Корневой `.env.example`
- Все переменные: `DB_*`, `PORT`/`API_PORT`, `CORS_ORIGIN`.
- **Без** реальных паролей; комментарии к каждой переменной.
---
## 3. Соответствие полей PLAN ↔ SQL ↔ JSON API
**В JSON API (запрос/ответ)****camelCase**, как в PLAN:
`id`, `date`, `title`, `distanceKm`, `status` (`planned` \| `registered` \| `completed`), `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`.
**В PostgreSQL****snake_case**, например:
| SQL column | Тип (рекомендация) |
|------------|---------------------|
| `id` | `TEXT` PRIMARY KEY |
| `race_date` | `DATE` |
| `title` | `TEXT` |
| `distance_km` | `NUMERIC(6,3)` |
| `status` | `TEXT` CHECK (опционально) |
| `official_url` | `TEXT` |
| `start_time` | `TEXT` |
| `cluster_schedule` | `TEXT` |
| `bib_pickup` | `TEXT` |
| `bib_number` | `TEXT` |
| `finish_time` | `TEXT` |
| `finish_place` | `TEXT` |
| `notes` | `TEXT` |
| `created_at` | `TIMESTAMPTZ` DEFAULT now() |
| `updated_at` | `TIMESTAMPTZ` |
Маппинг **строго** в одном модуле (например `backend/src/mappers/race.ts`), чтобы фронт всегда видел camelCase.
**Типы `date`:** в API строка `YYYY-MM-DD`. **`distanceKm`:** число. **`finishTime`:** строка времени как в PLAN; бэкенд **не обязан** парсить для бизнес-логики (PR считает фронт), но может валидировать формат по желанию.
---
## 4. Обязательный итог для упрощения фронтенда
После того как код бэкенда готов, агент **обязан** добавить в репозиторий документ:
### Файл: `docs/backend-api-for-frontend.md`
В нём **кратко и без пробелов в фактах**:
1. **Base URL** — что подставлять во фронт (`VITE_API_BASE_URL`), пример для dev.
2. **CORS** — какой `CORS_ORIGIN` ожидается в dev.
3. **Таблица эндпоинтов** — метод, путь, query, тело запроса (пример JSON), пример успешного ответа, коды ошибок.
4. **Модель `Race`** — перечень полей в **camelCase** с типами и обязательностью для POST vs PATCH.
5. **Фильтр списка** — как именно работают `year` и `month` на `GET /races` (включая границы: пустой месяц, только год и т.д.).
6. **Идемпотентность seed** — одна фраза: upsert по `id`, откуда берётся CSV.
7. **Поведение при недоступной БД** — что возвращает API / что в логах (как в реализации).
Дополнительно можно дублировать суть в **`docs/backend.md`** (общая эксплуатация: docker, migrate, seed, запуск), но **`backend-api-for-frontend.md`** — главная «шпаргалка» для разработчика SPA.
---
## 5. Критерии готовности (чеклист агента)
- [ ] `docker-compose.yml` поднимает Postgres с параметрами, совместимыми с `.env.example`.
- [ ] Миграция создаёт `races`; есть команда миграции.
- [ ] Реализованы `GET /races`, `GET /races/:id`, `POST /races`, `PATCH /races/:id` согласно PLAN.
- [ ] Реализован seed из `import/races_2026_calendar.csv`.
- [ ] `GET /health` (и при наличии `/ready` — описано).
- [ ] Корневой `.env.example` обновлён.
- [ ] Написан **`docs/backend-api-for-frontend.md`** (раздел 4).
- [ ] `docs/backend.md` содержит команды: установка зависимостей, migrate, seed, `npm run dev` для API.
---
## 6. Не входит в объём бэкенда (напоминание)
- Авторизация, пользователи, сессии.
- Парсинг сайтов организаторов.
- Отдача статики фронта с того же процесса (фронт — отдельно Vite/build).
---
*Конец инструкции. Источник требований к продукту — всегда [PLAN.md](../PLAN.md).*

View File

@@ -1,77 +0,0 @@
# Инструкция агенту: устранение рассинхронизации backend с планом/контрактом
Документ описывает, как выполнить план исправлений **только через изменения кода** (без правок существующей документации в `docs/`).
## 1) Ветка и границы задачи
- Создать отдельную ветку по best practice, например: `fix/backend-api-validation-and-runtime-sync`.
- Не менять существующие файлы документации в `docs/` как способ "починить" замечания.
- Исправления вносятся в runtime-код и обязательные артефакты репозитория.
## 2) Обязательные изменения в коде
### A. Строгая валидация `GET /races`
Файл: `backend/src/routes/races.ts`
- Добавить явную проверку `year`:
- целое число;
- при невалидном значении вернуть `400`:
- `{"error":"validation_error","details":[...]}`
- Добавить явную проверку `month`:
- целое число в диапазоне `1..12`;
- при невалидном значении вернуть `400` в том же формате.
- Исключить передачу `NaN`/некорректных значений в SQL-параметры.
### B. Разделение ошибок валидации и ошибок БД
Файл: `backend/src/routes/races.ts`
- `400` использовать только для ошибок входа (query/body/params).
- `503 {"error":"database_unavailable"}` использовать только для технической недоступности БД.
- Сохранить единый JSON-формат ошибок во всех CRUD-маршрутах.
### C. Выравнивание конфигурации порта
Файл: `backend/src/config.ts`
- Поддержать оба env-подхода:
- приоритет `PORT`,
- затем `API_PORT`,
- затем fallback `3001`.
### D. Обязательный root-артефакт
Файл: `README.md` (в корне)
- Создать базовый `README.md` с:
- кратким описанием проекта,
- минимальным quick start,
- ссылками на текущие документы backend/API.
## 3) Допустимая реорганизация кода
- Можно добавить небольшие локальные helper-функции в `backend/src/routes/races.ts`.
- При необходимости можно вынести общие mapper/validator-хелперы в `backend/src/mappers/race.ts`, если это уменьшает дублирование.
- Не усложнять архитектуру: только то, что нужно для контракта и устойчивого поведения API.
## 4) Проверка результата
Минимум выполнить:
1. `npm run build` в `backend/` (типизация/сборка).
2. Проверка диагностики/линтов по измененным backend-файлам.
3. Smoke-сценарии API:
- `GET /health` -> `200`,
- `GET /ready` -> `200` при доступной БД или `503` при недоступной,
- `GET /races?year=bad` -> `400`,
- `GET /races?month=13` -> `400`,
- `GET /races?year=2026&month=5` -> корректный `200` и данные/пустой массив.
## 5) Критерии готовности (Definition of Done)
- Контракт валидации `GET /races` соблюден в runtime.
- Валидационные ошибки не маскируются под `database_unavailable`.
- Конфиг порта поддерживает `PORT` и `API_PORT` с правильным приоритетом.
- В репозитории есть корневой `README.md`.
- Никакие существующие документы в `docs/` не менялись для "закрытия" замечаний.

13
docs/auth-rollout.md Normal file
View File

@@ -0,0 +1,13 @@
# Auth rollout
Before applying auth and ownership migrations in production:
1. Run `pg_dump` for the target database and keep the dump until verification is complete.
2. Record the rollback command for restoring that dump if migration or legacy race claiming fails.
3. Record the current race count with `SELECT count(*) FROM races;`.
4. Run migrations.
5. After the first email-verified user claims legacy races, verify:
- `SELECT count(*) FROM races;` matches the pre-migration count.
- `SELECT count(*) FROM races WHERE owner_user_id IS NULL;` is `0` before planning the follow-up `NOT NULL` migration.
Seed/import after auth requires `SEED_OWNER_USER_ID` or `SEED_OWNER_EMAIL` once users exist. It inserts missing seed races for that owner and does not overwrite user-edited races.

View File

@@ -1,238 +1,167 @@
# Backend API — шпаргалка для фронтенда
# Backend API — контракт для frontend
## 1. Base URL
## Base URL и авторизация
```
VITE_API_BASE_URL=http://localhost:3001
```
SPA обращается к относительному префиксу `/api`. В dev Vite проксирует его на
`http://localhost:3001/api`; в Docker nginx проксирует на
`runners-calendar-backend:3000`.
В коде SPA: `import.meta.env.VITE_API_BASE_URL`.
Все маршруты `/api/races` требуют сессионную cookie авторизованного пользователя
с подтверждённым email. Записи принадлежат текущему пользователю: чужая запись
не видна и отвечает `404`.
## 2. CORS
После `POST /api/auth/login` или `GET /api/auth/me` клиент получает
`csrfToken`. Для каждого `POST`, `PATCH` и `DELETE` с активной сессией его нужно
передавать в заголовке `X-CSRF-Token`. Cookie передаётся браузером автоматически;
при отдельном origin запрос должен использовать credentials.
В dev-режиме бэкенд ожидает переменную:
## Аккаунт и сессии
```
CORS_ORIGIN=http://localhost:5173
```
Все маршруты этого раздела требуют авторизации и подтверждённого email. Для
изменяющих состояние запросов обязателен `X-CSRF-Token`.
Разрешены методы `GET`, `POST`, `PATCH`, `DELETE` и заголовок `Content-Type: application/json`.
### `GET /api/auth/sessions`
## 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,
"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",
"sessions": [{
"id": "UUID",
"createdAt": "ISO datetime",
"lastSeenAt": "ISO datetime",
"expiresAt": "ISO datetime",
"current": true
}]
}
```
### `POST /api/auth/password`
```json
{ "currentPassword": "…", "newPassword": "не менее 15 символов" }
```
Проверяет текущий пароль, сохраняет новый и отзывает все остальные активные
сессии. Успех: `200 { "ok": true }`; неверный текущий пароль: `400`
`invalid_current_password`.
### `DELETE /api/auth/sessions/:id`
Отзывает указанную активную сессию текущего пользователя. Успех: `204`. Если
отозвана текущая сессия, cookie очищается. Чужая, неактивная или отсутствующая
сессия отвечает `404`.
### `POST /api/auth/sessions/revoke-others`
Отзывает все активные сессии, кроме текущей. Успех: `200 { "ok": true }`.
## Служебные маршруты
### `GET /api/health`
Liveness без проверки БД:
```json
{ "status": "ok", "version": "<backend version>" }
```
### `GET /api/meta`
Версия для footer:
```json
{ "version": "<backend version>" }
```
### `GET /api/ready`
Проверяет БД. Успех: `{ "status": "ready", "db": "connected" }`.
Недоступная БД: `503 { "error": "database_unavailable", "db": "disconnected" }`.
## Забеги
### `GET /api/races`
Возвращает только забеги текущего пользователя, отсортированные по дате. Опциональные
query-параметры: `year` (целое число) и `month` (112); без результатов возвращается
`[]`.
### `GET /api/races/:id`
Возвращает один забег. `id` — UUID, который создаёт сервер. Не путайте его с `slug`:
`slug` создаёт клиент как читаемый ключ и он уникален в рамках одного пользователя.
### `POST /api/races`
Создаёт забег. Обязательные поля: `slug`, `date`, `title`, `distanceKm`.
Неизвестные поля отклоняются.
```json
{
"slug": "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
"startTime": "09:30"
}
```
**Обязательные поля:** `id`, `date`, `title`, `distanceKm`.
Успех: `201` и объект `Race`. Одинаковый `slug` у того же пользователя: `409`
`{ "error": "conflict", "details": ["Race with this slug already exists"] }`.
Если `coverImageUrl` не передан, backend может найти Open Graph-обложку по
`officialUrl`; это best-effort и не мешает созданию при ошибке внешнего сайта.
**Ответ 201:** созданный объект `Race`.
### `PATCH /api/races/:id`
**Ответ 400:**
Частично обновляет забег. Передайте хотя бы одно поле из модели ниже; неизвестные
поля и пустой объект отклоняются. Успех: `200` и обновлённый `Race`.
```json
{ "error": "validation_error", "details": ["Fields id, date, title, distanceKm are required"] }
```
### `DELETE /api/races/:id`
**Ответ 409:**
Удаляет забег. Успех: `204` без тела.
```json
{ "error": "conflict", "details": ["Race with this id already exists"] }
```
## Модель `Race`
---
| Поле | Тип | POST | PATCH | Ограничение |
|---|---|---:|---:|---|
| `id` | UUID | сервер | — | Read-only UUID сервера |
| `slug` | string | да | да | 1120 символов: буквы/цифры, разделённые `-` |
| `date` | string | да | да | Реальная дата `YYYY-MM-DD` |
| `title` | string | да | да | 1200 символов |
| `distanceKm` | number | да | да | Больше 0, не более 999.999 |
| `status` | string/null | нет | да | `planned`, `registered` или `completed` |
| `officialUrl` | HTTP(S) URL/null | нет | да | До 2048 символов |
| `coverImageUrl` | HTTP(S) URL/null | нет | да | До 2048 символов |
| `startTime` | string/null | нет | да | `HH:MM` или `HH:MM:SS` |
| `clusterSchedule` | string/null | нет | да | До 1000 символов |
| `bibPickup` | string/null | нет | да | До 500 символов |
| `bibNumber` | string/null | нет | да | До 100 символов |
| `finishTime` | string/null | нет | да | `SS`, `MM:SS` или `H:MM:SS` |
| `finishPlace` | string/null | нет | да | До 100 символов |
| `notes` | string/null | нет | да | До 5000 символов |
| `createdAt` | ISO datetime | сервер | — | Read-only |
| `updatedAt` | ISO datetime/null | сервер | — | Read-only |
### `PATCH /races/:id`
## Ошибки
Частичное обновление — передавать **только** изменяемые поля.
| Статус | Тело | Когда |
|---:|---|---|
| 400 | `{ "error": "validation_error", "details": ["…"] }` | Неверный UUID, query или тело запроса |
| 401 | `{ "error": "unauthorized", "details": ["Authentication required"] }` | Нет сессии |
| 403 | `csrf_error` / `email_not_verified` | Неверный CSRF-токен или email не подтверждён |
| 404 | `{ "error": "not_found", "details": ["Race not found"] }` | Забега нет либо он принадлежит другому пользователю |
| 409 | `conflict` | Дублирующий `slug` пользователя |
| 500 | `unknown_error` | Непредвиденная ошибка приложения |
| 503 | `database_unavailable` | База недоступна |
**Тело запроса (JSON):**
Некорректный JSON также отвечает `400 validation_error`.
```json
{
"finishTime": "1:45:30",
"finishPlace": "12/340",
"bibNumber": "1234",
"status": "completed"
}
```
## Seed
**Допустимые поля:** `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`** — целое число 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" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`.
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
- В логах сервера: строка ошибки с контекстом маршрута.
`npm run seed` выполняет upsert по `slug` и не перезаписывает пользовательские
изменения. После включения авторизации ему нужен `SEED_OWNER_USER_ID` или
`SEED_OWNER_EMAIL`.

View File

@@ -68,25 +68,26 @@ API слушает порт: **`PORT`** (если задан), иначе **`API
| Переменная | Описание | По умолчанию |
|---|---|---|
| `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) | выкл. |
| `DB_HOST` | Хост PostgreSQL | — |
| `DB_PORT` | Порт PostgreSQL | — |
| `DB_NAME` | Имя базы данных | — |
| `DB_USER` | Пользователь БД | — |
| `DB_PASSWORD` | Пароль БД | — |
| `PORT` | Порт API (приоритетнее `API_PORT`) | — |
| `API_PORT` | Порт API-сервера | `3001` |
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
**Без mock:** при отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`.
Фронтенд всегда обращается к API по префиксу `/api` на текущем origin. В локальной разработке этот префикс проксирует Vite (`frontend/vite.config.ts`) на `http://localhost:3001`; в Docker-стеке — nginx фронта проксирует на `runners-calendar-backend:3000`.
**С `CALENDAR_RUN_MOCK_DB=1`:** переменные `DB_*` не обязательны; реальный пул не поднимается. **Не использовать** mock для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и корректные `DB_*`.
При отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`.
Для тестов нужен отдельный PostgreSQL database `calendar_run_test`; `npm test` применяет миграции и очищает его перед запуском.
## Поведение при недоступной БД
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
- **`GET /health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
- **`GET /ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API).
- **`GET /api/health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
- **`GET /api/ready`** — пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет.
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
## Структура каталога
@@ -99,15 +100,21 @@ backend/
├── src/
│ ├── app.ts # фабрика Express (тесты)
│ ├── config.ts # загрузка и валидация env
│ ├── db.ts # pg Pool или mock
│ ├── db.ts # pg Pool
│ ├── index.ts # запуск сервера
│ ├── migrate.ts # раннер миграций
│ ├── seed.ts # разовый импорт CSV
│ ├── mappers/
│ │ └── race.ts # snake_case ↔ camelCase
│ └── routes/
│ ├── health.ts # /health, /ready
│ └── races.ts # CRUD /races
│ ├── health.ts # /api/health, /api/ready, /api/meta
│ └── races.ts # CRUD /api/races
├── package.json
└── tsconfig.json
```
## Docker: стек backend + frontend
Файл [`docker-compose.stack.yml`](../docker-compose.stack.yml) поднимает API и nginx со статикой SPA в **внешней** сети Docker (рядом с уже запущенным Postgres). Переменные — в **корневом** `.env` (шаблон [`.env.example`](../.env.example)): как минимум `DB_*`, `CORS_ORIGIN` (для выдачи фронта на порту 3033 задайте `http://localhost:3033`). Перед первым `up` файл `.env` должен существовать.
Порядок после старта контейнеров: `node dist/migrate.js` и `node dist/seed.js` внутри сервиса `runners-calendar-backend` (см. комментарии в compose-файле).

View File

@@ -0,0 +1,52 @@
# План корректировок: форма старта, время, календарь стартов
Краткое описание реализованных изменений в клиенте **runners-calendar** (версия клиента см. в футере приложения).
## 1. Форма старта (редактирование прошедшего события)
При **редактировании** старта, чья **дата уже в прошлом**, в блоке «Организация» скрыты поля, неактуальные после забега:
- сайт организатора;
- время старта;
- расписание кластеров;
- выдача номеров.
Значения по-прежнему хранятся в состоянии формы и отправляются при сохранении (не затираются). Утилита: `isRaceDateInPast` в `frontend/src/lib/raceMetrics.ts`.
## 2. Время старта
Вместо свободного текста — три селекта (часы, минуты, секунды), компонент `StartTimeSelects` в `frontend/src/components/StartTimeSelects.tsx`. Сохраняется строка `HH:mm:ss` или пусто → `null` в API. Поддерживается разбор старых значений `HH:mm` при загрузке.
## 3. Список на странице «Календарь стартов»
Для стартов со статусом **«внесите результат»** вся карточка — ссылка на `/races/:id/edit` с лёгким увеличением и тенью при наведении/фокусе (токен `--shadow-card-lift`).
## 4. Виды: список и календарь
- Переключатель **Список / Календарь**, выбор сохраняется в `sessionStorage` (`races-view-mode`).
- **Календарь:** загрузка гонок за выбранный **год** (без фильтра месяца в запросе), отображение сетки месяцев.
- При выборе **месяца** в фильтре — крупная сетка этого месяца и компактная навигация по остальным месяцам + «Весь год».
## 5. Ячейка даты в календаре
- Наведение или фокус: всплывающая панель — либо «Стартов нет» и кнопка **Добавить** (`/races/new?date=YYYY-MM-DD`), либо список стартов со ссылками на карточки и **Добавить**.
- Клик по числу — страница дня `/races/day/:ymd`.
## 6. Страница дня
Маршрут `races/day/:ymd`: список стартов на дату, пустое состояние, кнопка **Добавить** с предзаполнением даты через query.
## 7. Новый старт с датой из календаря
`RaceFormPage` читает query-параметр `?date=YYYY-MM-DD` при создании старта.
## Основные файлы
| Область | Файлы |
|--------|--------|
| Метрики даты | `frontend/src/lib/raceMetrics.ts`, `frontend/src/lib/calendarUtils.ts` |
| Форма | `frontend/src/pages/RaceFormPage.tsx`, `frontend/src/components/StartTimeSelects.tsx` |
| Список и календарь | `frontend/src/pages/RacesPage.tsx`, `frontend/src/components/RacesCalendar.tsx` |
| День | `frontend/src/pages/RaceDayPage.tsx`, `frontend/src/app/router.tsx` |
| Стили | `frontend/src/styles/global.css`, `frontend/src/styles/tokens.css` |
| API-док | `docs/backend-api-for-frontend.md` (формат `startTime`) |

132
docs/product-backlog.md Normal file
View File

@@ -0,0 +1,132 @@
# Backlog Calendar Run
**Срез репозитория: 12 июля 2026.** Это единый продуктовый и технический backlog, составленный по текущему коду, а не список абстрактных идей.
## Текущая база
| Контур | Версия | Уже работает |
|---|---:|---|
| Backend | `1.5.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. |
| Frontend | `0.8.0` | Защищённые маршруты и auth-экраны, экран аккаунта, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и интерактивным графиком, версия FE/BE в футере. |
## Правила версионирования и выпуска
1. BE и FE выпускаются независимо: задачам без серверного контракта не нужен искусственный bump BE.
2. Патч (`x.y.Z`) — исправление без изменения публичного контракта; минор (`x.Y.0`) — новая обратносуместимая возможность; мажор — только при несовместимом API или модели доступа.
3. Любой релиз с изменением API включает: обновлённый `docs/backend-api-for-frontend.md`, миграцию (если нужна), тесты контракта и проверку футера с фактическими версиями.
4. До миграций в production выполнить чек-лист из [auth-rollout.md](auth-rollout.md): backup, число стартов до/после миграции и проверка владельца legacy-данных.
## План релизов
| Очередь | BE | FE | Цель | Условие выпуска |
|---|---:|---:|---|---|
| P0 | `1.4.2` | `0.7.1` | Исправить риски текущего релиза и привести контракт в соответствие коду. | Нет невалидных записей, SSRF-защита, критические сценарии проверяются на PostgreSQL. |
| P1 | `1.5.0` | `0.8.0` | Дать пользователю контроль над аккаунтом и данными, улучшить работу с историей стартов. | Управление сессиями и экспорт доступны, фильтры не ломают существующий список. |
| P2 | `1.6.0` | `0.9.0` | Подготовка к старту: чек-листы, напоминания и календарная интеграция. | Напоминание отправляется не более одного раза, подписка/токен отзываемы. |
| P3 | `1.7.0` | `1.0.0` | Сделать персональный календарь эксплуатационно готовым. | Восстановление из backup проверено, есть E2E критического пути и аудит доступности. |
`2.0.0` не планируется заранее: его поводом может быть только подтверждённая потребность в совместном доступе, командах или публичных стартах — это изменит модель владельцев и доступа.
## P0 — BE 1.4.2 / FE 0.7.1
### Backend 1.4.2
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-142-04 | P0 | Выполнить backend-часть Ponytail-аудита | Pipeline поднимает чистый PostgreSQL, применяет все миграции и выполняет минимум CRUD + auth, включая legacy-сценарий миграции `004`. Самодельный SQL-эмулятор `CALENDAR_RUN_MOCK_DB` удалён; тесты работают с этой БД. `cross-env` удалён, если Windows не входит в список поддерживаемых сред разработки. |
| BE-142-01 | P0 | Актуализировать контракт API | Документированы `id` (UUID сервера) и `slug` (ключ пользователя), обязательная авторизация для `/races`, CSRF для мутаций, реальный ответ `/health`, все коды ошибок и формат `Race`. Старое описание не обещает несуществующий неавторизованный CRUD. |
| BE-142-02 | P0 | Ввести строгую валидацию создания и изменения старта | Одна схема на `POST`/`PATCH`: корректные дата и дистанция, enum статуса, допустимые форматы `startTime` и `finishTime`, URL, пределы длины текстов; неизвестные поля не игнорируются. Ошибка возвращает `400 validation_error` с полем/причиной. |
| BE-142-03 | P0 | Защитить получение обложки от SSRF | Автозагрузка принимает только безопасные HTTP(S)-адреса, запрещает loopback/private/link-local адреса и опасные редиректы, ограничивает число редиректов, размер и время ответа. При невозможности получить обложку старт всё равно создаётся без неё. Есть тесты на блокировку. |
| BE-142-05 | P1 | Подтвердить достаточность индексов по фактическим запросам | Для списка владельца с фильтром года/месяца снять `EXPLAIN ANALYZE`; индекс `(owner_user_id, race_date)` добавлять только если он нужен по плану запроса или размеру данных. |
### Frontend 0.7.1
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-071-00 | P0 | Выполнить frontend-часть Ponytail-аудита | Удалены артефакты TypeScript/Vite из Git и добавлены в `.gitignore`, неиспользуемый `getHealth` и тип `HealthResponse`. Кастомные поля даты и времени старта заменены нативными `input[type=date]` и `input[type=time]`; каталог названий стартов с внешними картинками удалён, остаются `coverImageUrl` и локальные fallback-изображения. Создание и обновление старта используют один payload полей. |
| FE-071-01 | P0 | Исправить семантику личного рекорда | Карточка «Последний личный рекорд» показывается только для результата, который действительно улучшил прошлое лучшее время на сопоставимой дистанции; иначе карточка называется «Последний результат» либо скрывается. |
| FE-071-02 | P0 | Валидировать время до отправки | Форма не принимает `1:99`, отрицательные и произвольные значения; пользователю показана ошибка у поля. Правило идентично серверному, но сервер остаётся источником истины. |
| FE-071-03 | P0 | Довести ошибки auth-форм до состояния UI | На регистрации, повторной отправке письма, запросе и сбросе пароля есть loading-состояние, обработка ошибки и защита от повторной отправки. Никаких необработанных Promise в браузере. |
| FE-071-04 | P1 | Корректно обработать истёкшую сессию и CSRF | После `401` пользователь возвращается на вход с сохранением целевого маршрута; после `csrf_error` интерфейс предлагает однократно обновить сессию и повторить действие, не теряя заполненную форму. |
| FE-071-05 | P0 | Завершать подтверждение email понятным экраном успеха | После успешной ссылки `/verify-email?token=…` заголовок явно сообщает, что email подтверждён; ввод email, инструкция и кнопка повторной отправки не показываются. Форма повторной отправки остаётся только для недействительной/отсутствующей ссылки. |
| FE-071-06 | P0 | Упростить ввод результата забега | Время вводится отдельными цифровыми полями «часы», «минуты», «секунды» без ручного ввода `:`. Часы — целое число ≥ 0, минуты и секунды — от 0 до 59; пустые минуты/секунды автоматически нормализуются в `0`. Итоговое время не показывает незаполненные старшие части: без часов — `MM:SS`, без часов и минут — `SS`. Место вводится числами «место» и «всего участников» (второе необязательно), проверяется, что место ≥ 1 и не больше общего числа; в текущий `finishPlace` без изменения API сохраняется `место/всего` либо только место. Старые значения `finishPlace` не теряются до редактирования результата. |
## P1 — BE 1.5.0 / FE 0.8.0
### Backend 1.5.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-150-01 | Готово | Управление паролем и сессиями | Выполнено в `1.5.0`: авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. |
| BE-150-02 | P1 | Удаление аккаунта и данных | Явно подтверждённый запрос удаляет аккаунт, его сессии и личные старты транзакционно; повторный запрос идемпотентен. Перед реализацией согласовать необходимость периода восстановления. |
| BE-150-03 | P1 | Обратносуместимая фильтрация стартов | Добавить документированные фильтры `status`, диапазон дат и диапазон дистанции, не меняя текущий ответ-массив `GET /races`. Валидация параметров и покрытие индексами — по фактическому query plan. |
### Frontend 0.8.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-080-01 | Готово | Экран «Аккаунт» | Выполнено в `0.8.0`: пользователь видит подтверждённый email, меняет пароль и видит/отзывает другие сессии. Удаление аккаунта остаётся отдельной задачей BE-150-02. |
| FE-080-02 | P1 | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции синхронизированы с URL; поиск по названию работает без новой зависимости. Годы в селекторе строятся из данных, а не ограничены окном «-2/+4». |
| FE-080-03 | P1 | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере. Сервер не нужен: данные уже принадлежат пользователю и загружены в UI. |
| FE-080-04 | Готово | Сделать на дашборде список последних стартов | Выполнено в `0.8.0`: «Последние старты» показывают до 5 завершённых стартов с клиентской пагинацией, фильтрами по дистанции и году и доступным переходом в старт. |
| FE-080-05 | Готово | Связать рекорды по дистанциям со стартами | Выполнено в `0.8.0`: карточка рекорда с найденным стартом ведёт на `/races/:id` и использует существующую анимацию связанных карточек. |
| FE-080-06 | Готово | Сделать график прогресса информативным и интерактивным | Выполнено в `0.8.0`: график имеет подписанные оси, компактные подписи, tooltip по наведению/фокусу и доступный переход в старт. |
## P2 — BE 1.6.0 / FE 0.9.0
### Backend 1.6.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-160-01 | P1 | Чек-листы подготовки к старту | Новая сущность задач старта: текст, состояние, срок и порядок. Задачи принадлежат тому же пользователю, что и старт; каскадно удаляются вместе со стартом. |
| BE-160-02 | P1 | Надёжные email-напоминания | Настройки пользователя и очередь напоминаний хранятся в БД; отправка идемпотентна, ошибки ретраятся ограниченно и логируются без PII. Расписание запуска — внешний cron/worker, а не `setInterval` в каждом web-инстансе. |
| BE-160-03 | P2 | Подписка на персональный iCalendar | Выдать отдельный отзывной секретный URL только для read-only календаря пользователя. Нужны лимитирование, отсутствие email/заметок в ICS и отзыв/ротация токена. |
### Frontend 0.9.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-090-01 | P1 | Чек-лист в карточке старта | Пользователь добавляет, завершает и переставляет задачи подготовки; на дашборде видит ближайшие незавершённые пункты. Пустое состояние объясняет, что делать. |
| FE-090-02 | P1 | Настройки напоминаний | Опт-ин по email и выбор сроков до старта; понятно отображаются часовой пояс и факт отключения рассылки. |
| FE-090-03 | P2 | Управление iCalendar-подпиской | Экран показывает URL только по явному действию, даёт скопировать, отозвать и выпустить новый. Одноразовый экспорт из `0.8.0` остаётся доступным без подписки. |
## P3 — BE 1.7.0 / FE 1.0.0
### Backend 1.7.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-170-01 | P0 | Эксплуатационный runbook | Описаны deploy, миграция, rollback, backup, restore, ротация секретов, SMTP/Turnstile и реакция на недоступную БД. Restore протестирован на копии production-данных. |
| BE-170-02 | P0 | Наблюдаемость без утечки данных | Структурированные логи с request ID, готовность БД, счётчики ошибок и alert на недоступность. Email, пароли, токены, cookie и текст заметок не логируются. |
| BE-170-03 | P1 | Машиночитаемый API-контракт | OpenAPI-спецификация синхронизирована с маршрутам и используется в CI для проверки ключевых ответов. Это предотвращает повторное расхождение документации и реализации. |
### Frontend 1.0.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-100-01 | P0 | Критический E2E-путь | Автоматически проверяются: регистрация/подтверждение (с тестовым адаптером), вход, создание старта, внесение результата, экспорт и выход. Тест запускается в CI против реального API и PostgreSQL. |
| FE-100-02 | P0 | Accessibility и мобильный аудит | Полный путь проходится клавиатурой; формы сообщают ошибки screen reader'у, focus не теряется после действий, контраст и touch-targets соответствуют WCAG 2.2 AA. Проверены малый экран и медленное соединение. |
| FE-100-03 | P1 | Полировка надёжности UI | Единые skeleton/empty/error состояния, retry для безопасных загрузок, защита от двойной отправки мутаций, проверка загрузки внешних обложек и сохранение версии FE/BE в футере. |
## Зависимости и решения до начала P2
| Решение | Нужен ответ | Почему блокирует |
|---|---|---|
| Канал напоминаний | Только email или ещё push/мессенджер? | Определяет модель согласия, worker и стоимость эксплуатации. |
| Планировщик | Какой внешний cron/worker доступен в production? | Нельзя надёжно рассылать напоминания таймером в web-процессе. |
| Удаление аккаунта | Немедленное удаление или 30-дневное восстановление? | Меняет схему данных и обязанность хранить архив. |
| Политика данных | Для каких стран и пользователей запускается сервис? | До публичного запуска нужны политика конфиденциальности, срок хранения и процесс обращения пользователя. |
## Явно не берём до подтверждения спроса
- общий доступ к календарям, клубы, тренеры и публичные профили;
- поиск и парсинг стартов из сайтов организаторов, кроме уже существующей необязательной обложки;
- нативные iOS/Android-приложения, платежи и социальная лента;
- серверную пагинацию: личный календарь пока мал, а изменение формы ответа `GET /races` без измеренной необходимости только усложнит FE и API.
## Рекомендуемый первый спринт
1. `BE-142-04` и `FE-071-00`: выполнить выводы Ponytail-аудита — удалить самодельную инфраструктуру и использовать возможности платформы.
2. `BE-142-01` и `BE-142-02`: зафиксировать настоящий контракт и закрыть trust boundary.
3. `BE-142-03`: убрать SSRF-риск до любого расширения автозагрузки обложек.
4. `FE-071-01``FE-071-06`: устранить неверную аналитику и ошибки критических форм, включая понятное завершение подтверждения email и ввод результата.

View File

@@ -1,2 +1,4 @@
# Base URL of the Calendar Run API (must match CORS_ORIGIN on the backend)
VITE_API_BASE_URL=http://localhost:3001
# Для production-регистрации укажите публичный site key Cloudflare Turnstile.
# Без значения локально используется dev bypass token, если он разрешён бэкендом.
# VITE_TURNSTILE_SITE_KEY=
# Полный список переменных окружения — в корневом .env.example репозитория.

View File

@@ -1,9 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calendar Run</title>
<title>Календарь стартов</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-frontend",
"version": "0.1.0",
"version": "0.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-frontend",
"version": "0.1.0",
"version": "0.8.2",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@@ -1,7 +1,7 @@
{
"name": "calendar-run-frontend",
"private": true,
"version": "0.1.0",
"version": "0.8.2",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Календарь стартов">
<defs>
<linearGradient id="bg" x1="12" y1="4" x2="52" y2="60" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1168d8" />
<stop offset="1" stop-color="#071927" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#bg)" />
<path
d="M18 20h28a5 5 0 0 1 5 5v21a5 5 0 0 1-5 5H18a5 5 0 0 1-5-5V25a5 5 0 0 1 5-5Z"
fill="#ffffff"
/>
<path d="M13 29h38" stroke="#d6e1ea" stroke-width="4" />
<path d="M23 14v11M41 14v11" stroke="#b9f24a" stroke-width="5" stroke-linecap="round" />
<path
d="M22 41c5-8 13-8 18 0M22 41h18"
fill="none"
stroke="#1168d8"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="44" cy="44" r="5" fill="#ff6f5e" />
</svg>

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

100
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,100 @@
import { requestJson, setCsrfToken } from "./http";
import type { AuthSession, AuthUser } from "./types";
interface AuthResponse {
user: AuthUser;
csrfToken: string | null;
}
function applyAuthResponse(response: AuthResponse): AuthResponse {
setCsrfToken(response.csrfToken);
return response;
}
export async function getCurrentUser(): Promise<AuthResponse> {
return applyAuthResponse(await requestJson<AuthResponse>("/auth/me"));
}
export async function register(payload: {
email: string;
password: string;
turnstileToken: string;
}): Promise<void> {
await requestJson<void>("/auth/register", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function login(payload: { email: string; password: string }): Promise<AuthResponse> {
return applyAuthResponse(
await requestJson<AuthResponse>("/auth/login", {
method: "POST",
body: JSON.stringify(payload),
}),
);
}
export async function logout(): Promise<void> {
await requestJson<void>("/auth/logout", { method: "POST" });
setCsrfToken(null);
}
export async function verifyEmail(token: string): Promise<void> {
await requestJson<void>("/auth/verify-email", {
method: "POST",
body: JSON.stringify({ token }),
});
}
export async function resendVerification(email: string): Promise<void> {
await requestJson<void>("/auth/resend-verification", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function forgotPassword(email: string): Promise<void> {
await requestJson<void>("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function resetPassword(token: string, password: string): Promise<void> {
await requestJson<void>("/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, password }),
});
}
export async function getSessions(): Promise<AuthSession[]> {
const response = await requestJson<{ sessions: AuthSession[] }>("/auth/sessions");
return response.sessions;
}
export async function changePassword(payload: { currentPassword: string; newPassword: string }): Promise<void> {
await requestJson<void>("/auth/password", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function revokeSession(sessionId: string): Promise<void> {
await requestJson<void>(`/auth/sessions/${sessionId}`, { method: "DELETE" });
}
export async function revokeOtherSessions(): Promise<void> {
await requestJson<void>("/auth/sessions/revoke-others", { method: "POST" });
}
export async function updateProfile(payload: {
profileUsername?: string | null;
isFutureRacesPublic?: boolean;
isCompletedRacesPublic?: boolean;
}): Promise<void> {
await requestJson<void>("/auth/profile", {
method: "PATCH",
body: JSON.stringify(payload),
});
}

View File

@@ -3,6 +3,13 @@ export type ApiErrorCode =
| "not_found"
| "database_unavailable"
| "conflict"
| "username_taken"
| "unauthorized"
| "email_not_verified"
| "csrf_error"
| "captcha_failed"
| "invalid_credentials"
| "invalid_token"
| "network_error"
| "unknown_error";
@@ -35,14 +42,52 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
value === "validation_error" ||
value === "not_found" ||
value === "database_unavailable" ||
value === "conflict"
value === "conflict" ||
value === "username_taken" ||
value === "unauthorized" ||
value === "email_not_verified" ||
value === "csrf_error" ||
value === "captcha_failed" ||
value === "invalid_credentials" ||
value === "invalid_token" ||
value === "unknown_error"
) {
return value;
}
return "unknown_error";
}
function isGatewayStatus(status: number): boolean {
return status === 502 || status === 503 || status === 504;
}
export function isStructuredApiErrorPayload(payload: unknown): payload is ApiErrorPayload {
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
return false;
}
return typeof (payload as ApiErrorPayload).error === "string";
}
export function toApiError(status: number, payload: unknown): ApiError {
if (isGatewayStatus(status) && !isStructuredApiErrorPayload(payload)) {
return new ApiError({
code: "network_error",
status,
message: "Сервер временно недоступен. Попробуйте обновить страницу.",
});
}
if (!isStructuredApiErrorPayload(payload) && (status === 401 || status === 403 || status === 404)) {
return new ApiError({
code: "network_error",
status,
message:
status === 404
? "API не найден по этому адресу. Проверьте прокси и префикс /api."
: "Запрос отклонён сервером. Проверьте переменную CORS_ORIGIN на бэкенде.",
});
}
const maybePayload = payload as ApiErrorPayload;
const code = normalizeApiCode(maybePayload?.error);
const details = Array.isArray(maybePayload?.details)
@@ -67,8 +112,24 @@ export function getApiErrorMessage(code: ApiErrorCode): string {
return "Сервис временно недоступен. Попробуйте позже.";
case "conflict":
return "Запись с таким идентификатором уже существует.";
case "username_taken":
return "Это имя пользователя уже занято.";
case "unauthorized":
return "Нужно войти в аккаунт.";
case "email_not_verified":
return "Подтвердите email, чтобы продолжить.";
case "csrf_error":
return "Сессия устарела. Обновите страницу и попробуйте снова.";
case "captcha_failed":
return "Проверка капчи не пройдена.";
case "invalid_credentials":
return "Неверный email или пароль.";
case "invalid_token":
return "Ссылка недействительна или устарела.";
case "network_error":
return "Не удалось связаться с сервером.";
case "unknown_error":
return "Сервер не смог обработать запрос. Попробуйте позже или обновите страницу.";
default:
return "Произошла неизвестная ошибка.";
}

View File

@@ -0,0 +1,10 @@
import { requestJson } from "./http";
export type BackendMetaResponse = {
version: string;
};
/** Версия бэкенда для футера (отдельный путь от /health — меньше ложных блокировок). */
export async function getBackendMeta(init?: RequestInit): Promise<BackendMetaResponse> {
return requestJson<BackendMetaResponse>("/meta", init);
}

View File

@@ -1,55 +1,123 @@
import { ApiError, toApiError } from "./errors";
import { ApiError, isStructuredApiErrorPayload, toApiError } from "./errors";
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "http://localhost:3001";
const API_ROOT = "/api";
let csrfToken: string | null = null;
export function setCsrfToken(token: string | null): void {
csrfToken = token;
}
function buildUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${API_BASE_URL}${normalizedPath}`;
return `${API_ROOT}${normalizedPath}`;
}
async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
const text = await response.text();
if (!text.trim()) {
return null;
}
try {
return await response.json();
} catch {
return null;
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
const trimmed = text.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
return null;
}
const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]);
/** Повтор при «пустом» 404: часто бывает при нескольких инстансах/прокси до полного деплоя. */
function shouldRetryIdempotentError(status: number, payload: unknown): boolean {
if (GATEWAY_RETRY_STATUSES.has(status)) {
return true;
}
return status === 404 && !isStructuredApiErrorPayload(payload);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
try {
const response = await fetch(buildUrl(path), {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
const method = (init?.method ?? "GET").toUpperCase();
const idempotent = method === "GET" || method === "HEAD";
const maxAttempts = idempotent ? 3 : 1;
if (response.status === 204) {
return undefined as T;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const defaultHeaders: Record<string, string> = {};
if (method !== "GET" && method !== "HEAD") {
defaultHeaders["Content-Type"] = "application/json";
}
if (csrfToken && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
defaultHeaders["X-CSRF-Token"] = csrfToken;
}
const response = await fetch(buildUrl(path), {
...init,
credentials: "include",
headers: {
...defaultHeaders,
...(init?.headers ?? {}),
},
});
if (response.status === 204) {
return undefined as T;
}
const payload = await parseResponseBody(response);
if (!response.ok) {
const retryable = idempotent && attempt < maxAttempts && shouldRetryIdempotentError(response.status, payload);
if (retryable) {
await delay(80 * attempt);
continue;
}
throw toApiError(response.status, payload);
}
return payload as T;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
const retryable = idempotent && attempt < maxAttempts;
if (retryable) {
await delay(80 * attempt);
continue;
}
throw new ApiError({
code: "network_error",
status: null,
message: "Не удалось связаться с сервером.",
});
}
const payload = await parseResponseBody(response);
if (!response.ok) {
throw toApiError(response.status, payload);
}
return payload as T;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError({
code: "network_error",
status: null,
message: "Не удалось связаться с сервером.",
});
}
throw new ApiError({
code: "network_error",
status: null,
message: "Не удалось связаться с сервером.",
});
}

View File

@@ -1,3 +1,21 @@
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export type { AuthSession, AuthUser, CreateRacePayload, PublicRace, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export { ApiError, getApiErrorMessage } from "./errors";
export type { BackendMetaResponse } from "./health";
export { getBackendMeta } from "./health";
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
export { getPublicRaces } from "./users";
export {
forgotPassword,
changePassword,
getCurrentUser,
getSessions,
login,
logout,
register,
resendVerification,
resetPassword,
revokeOtherSessions,
revokeSession,
updateProfile,
verifyEmail,
} from "./auth";

View File

@@ -10,11 +10,16 @@ function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
function isOptionalNullableString(value: unknown): value is string | null | undefined {
return value === undefined || isNullableString(value);
}
function normalizeRace(input: unknown): Race {
const race = input as Partial<Race>;
const isValid =
isString(race?.id) &&
isString(race?.slug) &&
isString(race?.date) &&
isString(race?.title) &&
typeof race?.distanceKm === "number" &&
@@ -23,6 +28,7 @@ function normalizeRace(input: unknown): Race {
race?.status === "registered" ||
race?.status === "completed") &&
isNullableString(race?.officialUrl) &&
isOptionalNullableString(race?.coverImageUrl) &&
isNullableString(race?.startTime) &&
isNullableString(race?.clusterSchedule) &&
isNullableString(race?.bibPickup) &&
@@ -43,11 +49,13 @@ function normalizeRace(input: unknown): Race {
return {
id: race.id,
slug: race.slug,
date: race.date,
title: race.title,
distanceKm: race.distanceKm,
status: race.status,
officialUrl: race.officialUrl,
coverImageUrl: race.coverImageUrl ?? null,
startTime: race.startTime,
clusterSchedule: race.clusterSchedule,
bibPickup: race.bibPickup,
@@ -77,8 +85,8 @@ function buildRacesQuery(query?: RacesQuery): string {
return serialized ? `?${serialized}` : "";
}
export async function getRaces(query?: RacesQuery): Promise<Race[]> {
const response = await requestJson<unknown[]>(`/races${buildRacesQuery(query)}`);
export async function getRaces(query?: RacesQuery, init?: RequestInit): Promise<Race[]> {
const response = await requestJson<unknown[]>(`/races${buildRacesQuery(query)}`, init);
if (!Array.isArray(response)) {
throw new ApiError({
code: "unknown_error",
@@ -90,8 +98,8 @@ export async function getRaces(query?: RacesQuery): Promise<Race[]> {
return response.map(normalizeRace);
}
export async function getRaceById(id: string): Promise<Race> {
return normalizeRace(await requestJson<unknown>(`/races/${id}`));
export async function getRaceById(id: string, init?: RequestInit): Promise<Race> {
return normalizeRace(await requestJson<unknown>(`/races/${id}`, init));
}
export async function createRace(payload: CreateRacePayload): Promise<Race> {

View File

@@ -2,11 +2,13 @@ export type RaceStatus = "planned" | "registered" | "completed";
export interface Race {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
status: RaceStatus | null;
officialUrl: string | null;
coverImageUrl: string | null;
startTime: string | null;
clusterSchedule: string | null;
bibPickup: string | null;
@@ -24,12 +26,13 @@ export interface RacesQuery {
}
export interface CreateRacePayload {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
status?: RaceStatus | null;
officialUrl?: string | null;
coverImageUrl?: string | null;
startTime?: string | null;
clusterSchedule?: string | null;
bibPickup?: string | null;
@@ -40,3 +43,30 @@ export interface CreateRacePayload {
}
export type UpdateRacePayload = Partial<Omit<CreateRacePayload, "id">>;
export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
}
export interface PublicRace {
date: string;
title: string;
distanceKm: number;
status: RaceStatus | null;
coverImageUrl: string | null;
finishTime: string | null;
finishPlace: string | null;
}
export interface AuthSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}

24
frontend/src/api/users.ts Normal file
View File

@@ -0,0 +1,24 @@
import { ApiError } from "./errors";
import { requestJson } from "./http";
import type { PublicRace } from "./types";
function isPublicRace(value: unknown): value is PublicRace {
const race = value as Partial<PublicRace>;
return (
typeof race?.date === "string" &&
typeof race?.title === "string" &&
typeof race?.distanceKm === "number" &&
(race?.status === null || race?.status === "planned" || race?.status === "registered" || race?.status === "completed") &&
(race?.coverImageUrl === null || typeof race?.coverImageUrl === "string") &&
(race?.finishTime === null || typeof race?.finishTime === "string") &&
(race?.finishPlace === null || typeof race?.finishPlace === "string")
);
}
export async function getPublicRaces(username: string, init?: RequestInit): Promise<PublicRace[]> {
const response = await requestJson<unknown[]>(`/users/${encodeURIComponent(username)}/races`, init);
if (!Array.isArray(response) || !response.every(isPublicRace)) {
throw new ApiError({ code: "unknown_error", message: "Некорректный формат данных от API." });
}
return response;
}

View File

@@ -0,0 +1,68 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { AuthUser } from "../../api";
import { ApiError, getCurrentUser, login as loginRequest, logout as logoutRequest } from "../../api";
interface AuthContextValue {
user: AuthUser | null;
isLoading: boolean;
login(email: string, password: string): Promise<void>;
logout(): Promise<void>;
refresh(): Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider(props: { children: React.ReactNode }): JSX.Element {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const response = await getCurrentUser();
setUser(response.user);
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
setUser(null);
return;
}
setUser(null);
}
}, []);
useEffect(() => {
let mounted = true;
void refresh().finally(() => {
if (mounted) {
setIsLoading(false);
}
});
return () => {
mounted = false;
};
}, [refresh]);
const login = useCallback(async (email: string, password: string) => {
const response = await loginRequest({ email, password });
setUser(response.user);
}, []);
const logout = useCallback(async () => {
await logoutRequest();
setUser(null);
}, []);
const value = useMemo(
() => ({ user, isLoading, login, logout, refresh }),
[user, isLoading, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used inside AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,26 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "./AuthContext";
export function RequireAuth(): JSX.Element {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<section className="page page--auth" aria-busy="true">
<h1 className="page__title">Загрузка</h1>
<p className="page__subtitle">Проверяем сессию...</p>
</section>
);
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
if (!user.emailVerifiedAt) {
return <Navigate to="/verify-email" replace />;
}
return <Outlet />;
}

View File

@@ -1,11 +1,17 @@
import { NavLink, Outlet } from "react-router-dom";
import { Link, NavLink, Outlet } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { AppShellFooter } from "./AppShellFooter";
export function AppLayout(): JSX.Element {
const { user, logout } = useAuth();
return (
<div className="app-shell">
<header className="app-shell__header">
<div className="app-shell__brand">Calendar Run</div>
<nav className="app-shell__nav" aria-label="Primary navigation">
<Link className="app-shell__brand" to="/">
Календарь стартов
</Link>
<nav className="app-shell__nav" aria-label="Основная навигация">
<NavLink
to="/"
end
@@ -13,7 +19,7 @@ export function AppLayout(): JSX.Element {
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Dashboard
Обзор
</NavLink>
<NavLink
to="/races"
@@ -21,13 +27,46 @@ export function AppLayout(): JSX.Element {
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Races
Старты
</NavLink>
<NavLink
to="/races/new"
className={({ isActive }) =>
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
+ Добавить
</NavLink>
{user ? (
<>
<NavLink
to="/account"
className={({ isActive }) =>
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Аккаунт
</NavLink>
<button className="app-shell__link app-shell__link--button" type="button" onClick={() => void logout()}>
Выйти
</button>
</>
) : (
<NavLink
to="/login"
className={({ isActive }) =>
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Войти
</NavLink>
)}
</nav>
</header>
<main className="app-shell__main">
<Outlet />
</main>
<AppShellFooter />
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { getBackendMeta } from "../../api";
import { FRONTEND_VERSION } from "../../frontendVersion";
import { readCachedBackendVersion, writeCachedBackendVersion } from "../../lib/backendVersionCache";
function isAbortError(error: unknown): boolean {
return (
(error instanceof DOMException && error.name === "AbortError") ||
(error instanceof Error && error.name === "AbortError")
);
}
export function AppShellFooter(): JSX.Element {
const [backendVersion, setBackendVersion] = useState<string | null>(() => readCachedBackendVersion());
useEffect(() => {
const ac = new AbortController();
void getBackendMeta({ signal: ac.signal })
.then((meta) => {
if (ac.signal.aborted) {
return;
}
const v = meta.version;
const label = typeof v === "string" && v.length > 0 ? v : "не указана";
writeCachedBackendVersion(label);
setBackendVersion(label);
})
.catch((err) => {
if (ac.signal.aborted || isAbortError(err)) {
return;
}
setBackendVersion((prev) => {
const cached = readCachedBackendVersion();
if (cached) {
return cached;
}
if (prev !== null) {
return prev;
}
return "недоступна";
});
});
return () => ac.abort();
}, []);
const backendLabel = backendVersion === null ? "…" : backendVersion;
return (
<footer className="app-shell__footer">
<p className="app-shell__footer-versions">
Версия клиента: {FRONTEND_VERSION}
<span className="app-shell__footer-sep" aria-hidden="true">
{" · "}
</span>
Версия сервера: {backendLabel}
</p>
</footer>
);
}

View File

@@ -3,15 +3,36 @@ import { AppLayout } from "./layouts/AppLayout";
import { DashboardPage } from "../pages/DashboardPage";
import { RacesPage } from "../pages/RacesPage";
import { RaceDetailsPage } from "../pages/RaceDetailsPage";
import { RaceFormPage } from "../pages/RaceFormPage";
import { RaceDayPage } from "../pages/RaceDayPage";
import { AccountPage } from "../pages/AccountPage";
import { PublicProfilePage } from "../pages/PublicProfilePage";
import { ForgotPasswordPage, LoginPage, RegisterPage, ResetPasswordPage, VerifyEmailPage } from "../pages/AuthPages";
import { RequireAuth } from "./auth/RequireAuth";
export const appRouter = createBrowserRouter([
{
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "races", element: <RacesPage /> },
{ path: "races/:raceId", element: <RaceDetailsPage /> },
{ path: "login", element: <LoginPage /> },
{ path: "register", element: <RegisterPage /> },
{ path: "verify-email", element: <VerifyEmailPage /> },
{ path: "forgot-password", element: <ForgotPasswordPage /> },
{ path: "reset-password", element: <ResetPasswordPage /> },
{ path: "users/:username", element: <PublicProfilePage /> },
{
element: <RequireAuth />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "account", element: <AccountPage /> },
{ path: "races", element: <RacesPage /> },
{ path: "races/new", element: <RaceFormPage /> },
{ path: "races/day/:ymd", element: <RaceDayPage /> },
{ path: "races/:raceId", element: <RaceDetailsPage /> },
{ path: "races/:raceId/edit", element: <RaceFormPage /> },
],
},
],
},
]);

View File

@@ -1,73 +1,107 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import type { Race } from "../api";
import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds } from "../lib";
import { formatRaceDate, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } 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;
function formatAxisTime(minutes: number): string {
const totalSeconds = Math.round(minutes * 60);
const hours = Math.floor(totalSeconds / 3600);
const rest = totalSeconds % 3600;
return hours ? `${hours}:${String(Math.floor(rest / 60)).padStart(2, "0")}` : `${Math.floor(rest / 60)}:${String(rest % 60).padStart(2, "0")}`;
}
function formatShortDate(date: string): string {
return parseRaceDate(date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
}
/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
const navigate = useNavigate();
const [activeId, setActiveId] = useState<string | null>(null);
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 };
});
.filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null)
.sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime())
.map((race) => ({ race, minutes: parseFinishTimeToSeconds(race.finishTime)! / 60 }));
if (series.length < 2) {
return (
<p className="pace-chart__empty">
Нужно минимум два завершённых старта с временем на выбранной дистанции.
</p>
);
return <p className="pace-chart__empty">Нужно минимум два завершённых старта с временем на выбранной дистанции.</p>;
}
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 values = series.map((item) => item.minutes);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
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]!;
const h = 58;
const margin = { top: 6, right: 4, bottom: 14, left: 17 };
const innerW = w - margin.left - margin.right;
const innerH = h - margin.top - margin.bottom;
const pointAt = (item: (typeof series)[number], index: number) => ({
x: margin.left + (index / (series.length - 1)) * innerW,
y: margin.top + ((max - item.minutes) / range) * innerH,
});
const points = series.map(pointAt);
const line = points.map(({ x, y }) => `${x},${y}`).join(" ");
const yTicks = [max, (max + min) / 2, min];
const labelStep = Math.max(1, Math.ceil(series.length / 6));
const active = series.find((item) => item.race.id === activeId) ?? null;
return (
<div className="pace-chart">
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика времени на дистанции">
<polyline className="pace-chart__line" fill="none" points={points} />
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="group" aria-label="Динамика времени на дистанции. Нажмите точку, чтобы открыть старт.">
<text className="pace-chart__axis-title" x="2" y={h / 2} transform={`rotate(-90 2 ${h / 2})`}>Время</text>
{yTicks.map((tick) => {
const y = margin.top + ((max - tick) / range) * innerH;
return (
<g key={tick}>
<line className="pace-chart__grid-line" x1={margin.left} y1={y} x2={w - margin.right} y2={y} />
<text className="pace-chart__axis-label" x={margin.left - 2} y={y + 1} textAnchor="end">{formatAxisTime(tick)}</text>
</g>
);
})}
<line className="pace-chart__axis" x1={margin.left} y1={margin.top} x2={margin.left} y2={h - margin.bottom} />
<line className="pace-chart__axis" x1={margin.left} y1={h - margin.bottom} x2={w - margin.right} y2={h - margin.bottom} />
<polyline className="pace-chart__line" fill="none" points={line} />
{series.map((item, index) => {
const point = points[index]!;
const isLabeled = index % labelStep === 0 || index === series.length - 1;
return (
<g
key={item.race.id}
className="pace-chart__point"
tabIndex={0}
role="button"
aria-label={`Открыть ${item.race.title}: ${item.race.finishTime}, ${getPaceLabel(item.race.finishTime, item.race.distanceKm)}`}
onMouseEnter={() => setActiveId(item.race.id)}
onMouseLeave={() => setActiveId(null)}
onFocus={() => setActiveId(item.race.id)}
onBlur={() => setActiveId(null)}
onClick={() => navigate(`/races/${item.race.id}`)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
navigate(`/races/${item.race.id}`);
}
}}
>
<circle className={index === series.length - 1 ? "pace-chart__dot pace-chart__dot--last" : "pace-chart__dot"} cx={point.x} cy={point.y} r="1.05" />
{isLabeled ? <text className="pace-chart__point-label" x={point.x} y={h - margin.bottom + 5} textAnchor="middle">{formatShortDate(item.race.date)}</text> : null}
</g>
);
})}
<text className="pace-chart__axis-title" x={w / 2} y={h - 1} textAnchor="middle">Дата старта</text>
</svg>
<p className="pace-chart__caption">
Последний пункт: {formatRaceDate(last.race.date)} {last.race.finishTime} (
{last.minutes.toFixed(1)} мин)
</p>
{active ? (
<div className="pace-chart__tooltip" role="status">
<strong>{active.race.title}</strong>
<span>{formatRaceDate(active.race.date)} · {active.race.finishTime} · {getPaceLabel(active.race.finishTime, active.race.distanceKm)}</span>
</div>
) : <p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>}
</div>
);
}

View File

@@ -0,0 +1,291 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { Race } from "../api";
import { buildMonthCells, groupRacesByYmd, isRaceDateInPast, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
const MONTH_NAMES_RU_SHORT = [
"янв.",
"февр.",
"мар.",
"апр.",
"май",
"июн.",
"июл.",
"авг.",
"сент.",
"окт.",
"нояб.",
"дек.",
];
const POPOVER_LEAVE_MS = 140;
function toLocalYmd(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
interface RacesCalendarProps {
displayYear: number;
monthFilter: string;
races: Race[];
onMonthFilterChange: (value: string) => void;
}
function DayPopover(props: {
ymd: string;
races: Race[];
onCancelClose: () => void;
onScheduleClose: () => void;
}): JSX.Element {
const { ymd, races, onCancelClose, onScheduleClose } = props;
const hasRaces = races.length > 0;
return (
<div
className="races-cal__popover"
role="tooltip"
onMouseEnter={onCancelClose}
onMouseLeave={onScheduleClose}
>
{hasRaces ? (
<ul className="races-cal__popover-list">
{races.map((r) => (
<li key={r.id} className="races-cal__popover-item">
<Link className="races-cal__popover-link" to={`/races/${r.id}`} onClick={onCancelClose}>
{r.title}
</Link>
</li>
))}
</ul>
) : (
<p className="races-cal__popover-empty">Стартов нет</p>
)}
<Link
className="btn btn--secondary races-cal__popover-add"
to={`/races/new?date=${ymd}`}
onClick={onCancelClose}
>
Добавить
</Link>
</div>
);
}
function CalendarMonthBlock(props: {
year: number;
monthIndex: number;
racesByYmd: Map<string, Race[]>;
compact: boolean;
navigate: ReturnType<typeof useNavigate>;
openYmd: string | null;
setOpenYmd: (v: string | null) => void;
scheduleClose: () => void;
cancelClose: () => void;
onMonthSelect?: (monthIndex: number) => void;
todayYmd: string;
}): JSX.Element {
const {
year,
monthIndex,
racesByYmd,
compact,
navigate,
openYmd,
setOpenYmd,
scheduleClose,
cancelClose,
onMonthSelect,
todayYmd,
} = props;
const cells = useMemo(() => buildMonthCells(year, monthIndex), [year, monthIndex]);
const title = `${MONTH_NAMES_RU_SHORT[monthIndex]} ${year}`;
const blockClass = compact ? "races-cal__month races-cal__month--compact" : "races-cal__month";
return (
<div className={blockClass}>
<h3 className="races-cal__month-title">
{onMonthSelect ? (
<button
type="button"
className="races-cal__month-title-button"
onClick={() => {
onMonthSelect(monthIndex);
}}
>
{title}
</button>
) : (
title
)}
</h3>
<div className="races-cal__weekdays" aria-hidden>
{WEEKDAY_LABELS_SHORT_RU.map((d) => (
<span key={d} className="races-cal__weekday">
{d}
</span>
))}
</div>
<div className="races-cal__cells">
{cells.map((day, idx) => {
if (day === null) {
return <div key={`e-${idx}`} className="races-cal__cell races-cal__cell--empty" />;
}
const ymd = toYmd(year, monthIndex, day);
const dayRaces = racesByYmd.get(ymd) ?? [];
const hasRaces = dayRaces.length > 0;
const isOpen = openYmd === ymd;
const isPast = isRaceDateInPast(ymd);
const isToday = ymd === todayYmd;
const cellClassName = [
"races-cal__cell",
hasRaces ? "races-cal__cell--has-race" : "",
isOpen ? "races-cal__cell--open" : "",
isPast ? "races-cal__cell--past" : "",
isToday ? "races-cal__cell--today" : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={ymd}
className={cellClassName}
onMouseEnter={() => {
cancelClose();
setOpenYmd(hasRaces ? ymd : null);
}}
onMouseLeave={scheduleClose}
>
<button
type="button"
className="races-cal__day-btn"
onClick={() => {
navigate(`/races/day/${ymd}`);
}}
onFocus={() => {
cancelClose();
setOpenYmd(hasRaces ? ymd : null);
}}
onBlur={(e) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.closest(".races-cal__cell")?.contains(next)) {
return;
}
scheduleClose();
}}
>
{day}
</button>
{isOpen && hasRaces ? (
<DayPopover
ymd={ymd}
races={dayRaces}
onCancelClose={cancelClose}
onScheduleClose={scheduleClose}
/>
) : null}
</div>
);
})}
</div>
</div>
);
}
export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
const { displayYear, monthFilter, races, onMonthFilterChange } = props;
const navigate = useNavigate();
const [openYmd, setOpenYmd] = useState<string | null>(null);
const leaveTimerRef = useRef<number | null>(null);
const cancelClose = useCallback(() => {
if (leaveTimerRef.current !== null) {
window.clearTimeout(leaveTimerRef.current);
leaveTimerRef.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
cancelClose();
leaveTimerRef.current = window.setTimeout(() => {
setOpenYmd(null);
leaveTimerRef.current = null;
}, POPOVER_LEAVE_MS);
}, [cancelClose]);
const racesByYmd = useMemo(() => groupRacesByYmd(races), [races]);
const todayYmd = useMemo(() => toLocalYmd(new Date()), []);
const focusedMonthIndex = monthFilter === "" ? null : parseInt(monthFilter, 10) - 1;
return (
<div className="races-cal">
<p className="races-cal__hint">Наведите на дату с забегом краткая информация. Клик страница дня.</p>
{focusedMonthIndex === null || Number.isNaN(focusedMonthIndex) ? (
<div className="races-cal__year">
{MONTH_NAMES_RU_SHORT.map((_, mi) => (
<CalendarMonthBlock
key={mi}
year={displayYear}
monthIndex={mi}
racesByYmd={racesByYmd}
compact
navigate={navigate}
openYmd={openYmd}
setOpenYmd={setOpenYmd}
scheduleClose={scheduleClose}
cancelClose={cancelClose}
onMonthSelect={(mi) => {
onMonthFilterChange(String(mi + 1));
setOpenYmd(null);
}}
todayYmd={todayYmd}
/>
))}
</div>
) : (
<div className="races-cal__month-focus">
<nav className="races-cal__month-nav" aria-label="Месяцы года">
{MONTH_NAMES_RU_SHORT.map((label, mi) => (
<button
key={label}
type="button"
className={`races-cal__month-nav-item${mi === focusedMonthIndex ? " races-cal__month-nav-item--active" : ""}`}
onClick={() => {
onMonthFilterChange(String(mi + 1));
}}
>
{label}
</button>
))}
<button
type="button"
className="races-cal__month-nav-item races-cal__month-nav-item--all"
onClick={() => {
onMonthFilterChange("");
}}
>
Весь год
</button>
</nav>
<CalendarMonthBlock
year={displayYear}
monthIndex={focusedMonthIndex}
racesByYmd={racesByYmd}
compact={false}
navigate={navigate}
openYmd={openYmd}
setOpenYmd={setOpenYmd}
scheduleClose={scheduleClose}
cancelClose={cancelClose}
todayYmd={todayYmd}
/>
</div>
)}
</div>
);
}

View File

@@ -1 +1,2 @@
export { PaceTrendChart } from "./PaceTrendChart";
export { RacesCalendar } from "./RacesCalendar";

View File

@@ -1 +0,0 @@
export {};

View File

@@ -0,0 +1,3 @@
import packageJson from "../package.json";
export const FRONTEND_VERSION: string = packageJson.version;

View File

@@ -0,0 +1,21 @@
const STORAGE_KEY = "calendar_run.backendVersion.v1";
export function readCachedBackendVersion(): string | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw !== null && raw.trim().length > 0 ? raw.trim() : null;
} catch {
return null;
}
}
export function writeCachedBackendVersion(version: string): void {
try {
if (version === "недоступна" || version === "не указана") {
return;
}
sessionStorage.setItem(STORAGE_KEY, version);
} catch {
// private mode / quota
}
}

View File

@@ -0,0 +1,53 @@
import type { Race } from "../api";
export const WEEKDAY_LABELS_SHORT_RU: string[] = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
/** Monday-based week: Mon=0 ... Sun=6 */
function mondayIndexFromJsDay(jsDay: number): number {
return (jsDay + 6) % 7;
}
/** Monday-based week: Mon=0 ... Sun=6 */
export function mondayIndexFromDate(d: Date): number {
return mondayIndexFromJsDay(d.getDay());
}
/** Grid cells for one month: `null` = empty, `1..31` = day of month. Padded to full weeks, at least 6 rows. */
export function buildMonthCells(year: number, monthIndex: number): (number | null)[] {
const lead = mondayIndexFromJsDay(new Date(Date.UTC(year, monthIndex, 1)).getUTCDay());
const dim = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
const cells: (number | null)[] = [];
for (let i = 0; i < lead; i += 1) {
cells.push(null);
}
for (let day = 1; day <= dim; day += 1) {
cells.push(day);
}
while (cells.length % 7 !== 0) {
cells.push(null);
}
while (cells.length < 42) {
cells.push(null);
}
return cells;
}
export function toYmd(year: number, monthIndex: number, day: number): string {
const m = String(monthIndex + 1).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}-${m}-${d}`;
}
export function groupRacesByYmd(races: Race[]): Map<string, Race[]> {
const map = new Map<string, Race[]>();
for (const race of races) {
const ymd = race.date.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) {
continue;
}
const list = map.get(ymd) ?? [];
list.push(race);
map.set(ymd, list);
}
return map;
}

View File

@@ -1,13 +1,21 @@
export {
formatDistance,
formatRaceDate,
findLatestPersonalRecord,
getPaceLabel,
getRaceCountdownLabel,
getRaceStatusClassName,
getRaceStatusLabel,
isCloseDistance,
isRaceDateInPast,
parseFinishTimeToSeconds,
parseRaceDate,
raceNeedsResultEntry,
sortByDateAsc,
sortByDateDesc,
splitRacesByDate,
} from "./raceMetrics";
export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils";
export { getRaceVisual } from "./raceVisuals";
export type { RaceVisualVariant } from "./raceVisuals";

View File

@@ -2,8 +2,21 @@ import type { Race } from "../api";
const MS_IN_DAY = 24 * 60 * 60 * 1000;
function parseRaceDate(date: string): Date {
return new Date(`${date}T00:00:00`);
/** API date: YYYY-MM-DD или ISO-строка от сериализации (не склеивать с «T00:00:00» повторно). */
export function parseRaceDate(date: string): Date {
const ymd = date.slice(0, 10);
if (/^\d{4}-\d{2}-\d{2}$/.test(ymd)) {
return new Date(`${ymd}T00:00:00`);
}
const parsed = new Date(date);
return parsed;
}
/** Дата старта (календарный день) строго раньше сегодняшней полуночи по локали. */
export function isRaceDateInPast(raceDate: string, now: Date = new Date()): boolean {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
return parseRaceDate(raceDate).getTime() < today.getTime();
}
export function parseFinishTimeToSeconds(value: string | null): number | null {
@@ -11,18 +24,29 @@ export function parseFinishTimeToSeconds(value: string | null): number | null {
return null;
}
const parts = value.split(":").map((part) => Number(part));
if (parts.some((part) => Number.isNaN(part) || part < 0)) {
const parts = value.split(":");
if (parts.some((part) => !/^\d+$/.test(part))) {
return null;
}
const values = parts.map(Number);
if (parts.length === 2) {
const [minutes, seconds] = parts;
if (values.length === 1) {
return values[0]! <= 59 ? values[0]! : null;
}
if (values.length === 2) {
const [minutes, seconds] = values;
if (minutes! > 59 || seconds! > 59) {
return null;
}
return minutes * 60 + seconds;
}
if (parts.length === 3) {
const [hours, minutes, seconds] = parts;
if (values.length === 3) {
const [hours, minutes, seconds] = values;
if (minutes! > 59 || seconds! > 59) {
return null;
}
return hours * 3600 + minutes * 60 + seconds;
}
@@ -49,6 +73,37 @@ export function sortByDateDesc(races: Race[]): Race[] {
return [...races].sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
}
/** Последний результат, который улучшил уже существовавший личный рекорд на сопоставимой дистанции. */
export function findLatestPersonalRecord(races: Race[]): Race | null {
const bestTimes: Array<{ distanceKm: number; seconds: number }> = [];
let latestRecord: Race | null = null;
for (const race of sortByDateAsc(races)) {
if (race.status !== "completed") {
continue;
}
const seconds = parseFinishTimeToSeconds(race.finishTime);
if (seconds === null) {
continue;
}
// ponytail: personal calendars are small; introduce a distance index only if profiling requires it.
const comparable = bestTimes.filter((best) => isCloseDistance(best.distanceKm, race.distanceKm));
if (comparable.length > 0 && seconds < Math.min(...comparable.map((best) => best.seconds))) {
latestRecord = race;
}
if (comparable.length === 0) {
bestTimes.push({ distanceKm: race.distanceKm, seconds });
} else {
comparable.forEach((best) => {
best.seconds = Math.min(best.seconds, seconds);
});
}
}
return latestRecord;
}
export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcoming: Race[]; past: Race[] } {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
@@ -70,6 +125,22 @@ export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcom
};
}
function pluralizeDays(n: number): string {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 19) {
return "дней";
}
if (mod10 === 1) {
return "день";
}
if (mod10 >= 2 && mod10 <= 4) {
return "дня";
}
return "дней";
}
export function getRaceCountdownLabel(date: string, now: Date = new Date()): string {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
@@ -80,13 +151,7 @@ export function getRaceCountdownLabel(date: string, now: Date = new Date()): str
if (days <= 0) {
return "сегодня";
}
if (days === 1) {
return "через 1 день";
}
if (days < 5) {
return `через ${days} дня`;
}
return `через ${days} дней`;
return `через ${days} ${pluralizeDays(days)}`;
}
export function isCloseDistance(left: number, right: number): boolean {
@@ -106,18 +171,36 @@ 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`;
function isPastDateNeedingResult(status: Race["status"], raceDate: string): boolean {
if (status !== "planned" && status !== "registered") {
return false;
}
if (status === "registered") {
return `${base} ${base}--registered`;
}
return `${base} ${base}--planned`;
const today = new Date();
today.setHours(0, 0, 0, 0);
return parseRaceDate(raceDate).getTime() < today.getTime();
}
export function getRaceStatusLabel(status: Race["status"]): string {
export function raceNeedsResultEntry(race: Race): boolean {
return isPastDateNeedingResult(race.status, race.date);
}
export function getRaceStatusClassName(status: Race["status"], raceDate?: string): string {
const base = "race-card__status";
let tier = `${base}--planned`;
if (status === "completed") {
tier = `${base}--completed`;
} else if (status === "registered") {
tier = `${base}--registered`;
}
const needs =
raceDate && isPastDateNeedingResult(status, raceDate) ? ` ${base}--needs-result` : "";
return `${base} ${tier}${needs}`;
}
export function getRaceStatusLabel(status: Race["status"], raceDate?: string): string {
if (raceDate && isPastDateNeedingResult(status, raceDate)) {
return "внесите результат";
}
if (status === "completed") {
return "пробежал";
}

View File

@@ -0,0 +1,92 @@
import type { Race } from "../api";
export type RaceVisualVariant = "short" | "half" | "marathon" | "trail" | "night";
export type RaceVisualFit = "cover" | "contain";
interface RaceVisual {
variant: RaceVisualVariant;
imageSrc: string;
fallbackSrc: string;
imageFit: RaceVisualFit;
label: string;
}
const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
short: {
variant: "short",
imageSrc: "/images/race-short.jpg",
fallbackSrc: "/images/race-short.jpg",
imageFit: "cover",
label: "Городской темп",
},
half: {
variant: "half",
imageSrc: "/images/race-half.jpg",
fallbackSrc: "/images/race-half.jpg",
imageFit: "cover",
label: "Полумарафон",
},
marathon: {
variant: "marathon",
imageSrc: "/images/race-marathon.jpg",
fallbackSrc: "/images/race-marathon.jpg",
imageFit: "cover",
label: "Марафон",
},
trail: {
variant: "trail",
imageSrc: "/images/race-trail.jpg",
fallbackSrc: "/images/race-trail.jpg",
imageFit: "cover",
label: "Трейл",
},
night: {
variant: "night",
imageSrc: "/images/race-night.jpg",
fallbackSrc: "/images/race-night.jpg",
imageFit: "cover",
label: "Ночной старт",
},
};
function getFallbackRaceVisual(race: Race): RaceVisual {
const title = race.title.toLowerCase();
if (title.includes("ночной")) {
return FALLBACK_VISUALS.night;
}
if (
title.includes("trail") ||
title.includes("extreme") ||
title.includes("suvorov") ||
title.includes("трейл") ||
title.includes("экстрим")
) {
return FALLBACK_VISUALS.trail;
}
if (Math.abs(race.distanceKm - 42.2) < 0.8) {
return FALLBACK_VISUALS.marathon;
}
if (Math.abs(race.distanceKm - 21.1) < 0.4) {
return FALLBACK_VISUALS.half;
}
return FALLBACK_VISUALS.short;
}
export function getRaceVisual(race: Race): RaceVisual {
const fallback = getFallbackRaceVisual(race);
if (race.coverImageUrl) {
return {
...fallback,
imageSrc: race.coverImageUrl,
fallbackSrc: fallback.imageSrc,
};
}
return fallback;
}

View File

@@ -1,12 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { AuthProvider } from "./app/auth/AuthContext";
import { appRouter } from "./app/router";
import "./styles/tokens.css";
import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<RouterProvider router={appRouter} />
<AuthProvider>
<RouterProvider router={appRouter} />
</AuthProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,227 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession, updateProfile } from "../api";
import type { AuthSession } from "../api";
import { useAuth } from "../app/auth/AuthContext";
function formatDate(value: string): string {
return new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
export function AccountPage(): JSX.Element {
const { user, logout, refresh } = useAuth();
const [sessions, setSessions] = useState<AuthSession[]>([]);
const [sessionsError, setSessionsError] = useState("");
const [sessionsLoading, setSessionsLoading] = useState(true);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [passwordError, setPasswordError] = useState("");
const [passwordSuccess, setPasswordSuccess] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false);
const [profileError, setProfileError] = useState("");
const [profileSuccess, setProfileSuccess] = useState("");
const [profileSaving, setProfileSaving] = useState(false);
const [profileUsername, setProfileUsername] = useState(user?.profileUsername ?? "");
const [futureRacesPublic, setFutureRacesPublic] = useState(user?.isFutureRacesPublic ?? false);
const [completedRacesPublic, setCompletedRacesPublic] = useState(user?.isCompletedRacesPublic ?? false);
const loadSessions = useCallback(async () => {
setSessionsLoading(true);
setSessionsError("");
try {
setSessions(await getSessions());
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось загрузить активные сессии.");
} finally {
setSessionsLoading(false);
}
}, []);
useEffect(() => {
void loadSessions();
}, [loadSessions]);
useEffect(() => {
setProfileUsername(user?.profileUsername ?? "");
setFutureRacesPublic(user?.isFutureRacesPublic ?? false);
setCompletedRacesPublic(user?.isCompletedRacesPublic ?? false);
}, [user?.profileUsername, user?.isFutureRacesPublic, user?.isCompletedRacesPublic]);
async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setPasswordError("");
setPasswordSuccess("");
if (newPassword !== passwordConfirmation) {
setPasswordError("Новый пароль и его повтор не совпадают.");
return;
}
setPasswordSaving(true);
try {
await changePassword({ currentPassword, newPassword });
setCurrentPassword("");
setNewPassword("");
setPasswordConfirmation("");
setPasswordSuccess("Пароль изменён. Остальные активные сессии завершены.");
await loadSessions();
} catch (error) {
setPasswordError(error instanceof ApiError ? error.message : "Не удалось изменить пароль.");
} finally {
setPasswordSaving(false);
}
}
async function handleSessionRevoke(session: AuthSession): Promise<void> {
try {
await revokeSession(session.id);
if (session.current) {
await logout();
return;
}
await loadSessions();
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось завершить сессию.");
}
}
async function handleOtherSessionsRevoke(): Promise<void> {
try {
await revokeOtherSessions();
await loadSessions();
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось завершить сессии.");
}
}
async function handleProfileSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setProfileSaving(true);
setProfileError("");
setProfileSuccess("");
try {
await updateProfile({
profileUsername: profileUsername.trim(),
isFutureRacesPublic: futureRacesPublic,
isCompletedRacesPublic: completedRacesPublic,
});
await refresh();
setProfileSuccess("Настройки личной страницы сохранены.");
} catch (error) {
setProfileError(error instanceof ApiError ? error.message : "Не удалось обновить видимость страницы.");
} finally {
setProfileSaving(false);
}
}
async function copyProfileUrl(): Promise<void> {
if (!user?.profileUsername) {
return;
}
setProfileError("");
try {
await navigator.clipboard.writeText(`${window.location.origin}/users/${user.profileUsername}`);
setProfileSuccess("Адрес скопирован.");
} catch {
setProfileError("Не удалось скопировать адрес.");
}
}
const profileUrl = user?.profileUsername ? `${window.location.origin}/users/${user.profileUsername}` : "";
return (
<section className="page page--auth">
<h1 className="page__title">Аккаунт</h1>
<p className="page__subtitle">{user?.email} email подтверждён.</p>
<form className="auth-form" aria-labelledby="profile-title" onSubmit={(event) => void handleProfileSubmit(event)}>
<h2 className="auth-form__title" id="profile-title">Публичная страница</h2>
<label className="auth-form__field">
<span className="auth-form__label">Имя пользователя</span>
<input
className="auth-form__input"
required
minLength={3}
maxLength={30}
pattern="[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
value={profileUsername}
onChange={(event) => setProfileUsername(event.target.value.toLowerCase())}
/>
</label>
<label className="auth-form__field">
<span>
<input
checked={futureRacesPublic}
disabled={profileSaving}
type="checkbox"
onChange={(event) => setFutureRacesPublic(event.target.checked)}
/>{" "}
Видимость будущих стартов
</span>
</label>
<label className="auth-form__field">
<span>
<input
checked={completedRacesPublic}
disabled={profileSaving}
type="checkbox"
onChange={(event) => setCompletedRacesPublic(event.target.checked)}
/>{" "}
Видимость завершённых стартов и результатов
</span>
</label>
<div className="auth-form__field">
<span className="auth-form__label">Адрес личной страницы</span>
<div className="account__profile-link">
<input className="auth-form__input" readOnly value={profileUrl} placeholder="Сначала задайте имя пользователя" />
<button className="btn btn--secondary" disabled={!profileUrl} type="button" onClick={() => void copyProfileUrl()}>Скопировать</button>
</div>
</div>
<p className="page__subtitle">Открытая страница не показывает личные заметки и номера участников.</p>
{profileError ? <p className="page__subtitle page__subtitle--error" role="alert">{profileError}</p> : null}
{profileSuccess ? <p className="account__success" role="status">{profileSuccess}</p> : null}
<button className="btn" disabled={profileSaving} type="submit">{profileSaving ? "Сохраняем…" : "Сохранить настройки"}</button>
</form>
<form className="auth-form" onSubmit={(event) => void handlePasswordSubmit(event)}>
<h2 className="auth-form__title">Сменить пароль</h2>
<label className="auth-form__field">
Текущий пароль
<input className="auth-form__input" required type="password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} />
</label>
<label className="auth-form__field">
Новый пароль
<input className="auth-form__input" required minLength={15} type="password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} />
</label>
<label className="auth-form__field">
Повторите новый пароль
<input className="auth-form__input" required minLength={15} type="password" value={passwordConfirmation} onChange={(event) => setPasswordConfirmation(event.target.value)} />
</label>
{passwordError ? <p className="page__subtitle page__subtitle--error" role="alert">{passwordError}</p> : null}
{passwordSuccess ? <p className="account__success" role="status">{passwordSuccess}</p> : null}
<button className="btn" disabled={passwordSaving} type="submit">{passwordSaving ? "Сохраняем…" : "Сменить пароль"}</button>
</form>
<section className="auth-form" aria-labelledby="sessions-title">
<h2 className="auth-form__title" id="sessions-title">Активные сессии</h2>
<p className="page__subtitle">После смены пароля все остальные сессии завершаются автоматически.</p>
{sessionsError ? <p className="page__subtitle page__subtitle--error" role="alert">{sessionsError}</p> : null}
{sessionsLoading ? <p>Загружаем сессии</p> : null}
{!sessionsLoading ? (
<ul className="account__sessions">
{sessions.map((session) => (
<li className="account__session" key={session.id}>
<div>
<strong>{session.current ? "Текущая сессия" : "Активная сессия"}</strong>
<p>Последняя активность: {formatDate(session.lastSeenAt)}</p>
<p>Создана: {formatDate(session.createdAt)}</p>
</div>
{!session.current ? <button className="btn btn--secondary" type="button" onClick={() => void handleSessionRevoke(session)}>Завершить</button> : null}
</li>
))}
</ul>
) : null}
{sessions.some((session) => !session.current) ? <button className="btn btn--secondary" type="button" onClick={() => void handleOtherSessionsRevoke()}>Завершить все остальные</button> : null}
</section>
</section>
);
}

View File

@@ -0,0 +1,328 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import {
ApiError,
forgotPassword,
register,
resendVerification,
resetPassword,
verifyEmail,
} from "../api";
import { useAuth } from "../app/auth/AuthContext";
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
return error.details.length > 0 ? error.details.join("; ") : error.message;
}
return fallback;
}
function TurnstileField(props: { onToken(token: string): void }): JSX.Element {
const ref = useRef<HTMLDivElement | null>(null);
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
const { onToken } = props;
useEffect(() => {
if (!siteKey || !ref.current) {
onToken("mock-turnstile-token");
return;
}
const scriptId = "turnstile-script";
if (!document.getElementById(scriptId)) {
const script = document.createElement("script");
script.id = scriptId;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
let widgetId: string | null = null;
const timer = window.setInterval(() => {
if (window.turnstile && ref.current && !widgetId) {
widgetId = window.turnstile.render(ref.current, {
sitekey: siteKey,
callback: onToken,
"expired-callback": () => onToken(""),
});
window.clearInterval(timer);
}
}, 100);
return () => {
window.clearInterval(timer);
if (widgetId && window.turnstile) {
window.turnstile.remove(widgetId);
}
};
}, [onToken, siteKey]);
return <div className="auth-form__captcha" ref={ref} />;
}
export function LoginPage(): JSX.Element {
const { user, login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (user?.emailVerifiedAt) {
return <Navigate to="/" replace />;
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await login(email, password);
const from = (location.state as { from?: Location } | null)?.from?.pathname ?? "/";
navigate(from, { replace: true });
} catch (err) {
setError(errorMessage(err, "Не удалось войти."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Вход</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>
Войти
</button>
</form>
<p className="auth-form__links">
<Link className="page-link" to="/register">Регистрация</Link>
<Link className="page-link" to="/forgot-password">Забыли пароль?</Link>
</p>
</section>
);
}
export function RegisterPage(): JSX.Element {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleToken = useCallback((token: string) => setTurnstileToken(token), []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await register({ email, password, turnstileToken });
setMessage("Проверьте почту: мы отправили ссылку для подтверждения email.");
} catch (err) {
setError(errorMessage(err, "Не удалось зарегистрироваться."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Регистрация</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<TurnstileField onToken={handleToken} />
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={!turnstileToken || isSubmitting}>
{isSubmitting ? "Создаём аккаунт…" : "Создать аккаунт"}
</button>
</form>
<p className="auth-form__links">
<Link className="page-link" to="/login">Уже есть аккаунт</Link>
</p>
</section>
);
}
export function VerifyEmailPage(): JSX.Element {
const { user, refresh } = useAuth();
const [params] = useSearchParams();
const [email, setEmail] = useState(user?.email ?? "");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const [isVerified, setIsVerified] = useState(false);
const [isResending, setIsResending] = useState(false);
useEffect(() => {
const token = params.get("token");
if (!token) {
return;
}
let isMounted = true;
setIsVerifying(true);
setError(null);
void (async () => {
try {
await verifyEmail(token);
try {
await refresh();
} catch {
// Подтверждение уже выполнено; обновление сессии не должно скрывать успех.
}
if (isMounted) {
setIsVerified(true);
}
} catch (err) {
if (isMounted) {
setError(errorMessage(err, "Ссылка недействительна."));
}
} finally {
if (isMounted) {
setIsVerifying(false);
}
}
})();
return () => {
isMounted = false;
};
}, [params, refresh]);
const resend = async () => {
setError(null);
setMessage(null);
setIsResending(true);
try {
await resendVerification(email);
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
} catch (err) {
setError(errorMessage(err, "Не удалось отправить письмо повторно."));
} finally {
setIsResending(false);
}
};
if (isVerified) {
return (
<section className="page page--auth">
<h1 className="page__title">Email подтверждён</h1>
<p className="page__subtitle">Теперь можно пользоваться календарём.</p>
<p className="auth-form__links"><Link className="page-link" to="/">Перейти к календарю</Link></p>
</section>
);
}
return (
<section className="page page--auth">
<h1 className="page__title">Подтверждение email</h1>
<p className="page__subtitle">{isVerifying ? "Проверяем ссылку…" : "Для доступа к календарю подтвердите email по ссылке из письма."}</p>
<form className="auth-form" onSubmit={(e) => { e.preventDefault(); void resend(); }}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isVerifying || isResending}>
{isResending ? "Отправляем…" : "Отправить письмо ещё раз"}
</button>
</form>
</section>
);
}
export function ForgotPasswordPage(): JSX.Element {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await forgotPassword(email);
setMessage("Если email зарегистрирован, ссылка отправлена.");
} catch (err) {
setError(errorMessage(err, "Не удалось отправить ссылку."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Сброс пароля</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>{isSubmitting ? "Отправляем…" : "Отправить ссылку"}</button>
</form>
</section>
);
}
export function ResetPasswordPage(): JSX.Element {
const [params] = useSearchParams();
const [password, setPassword] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await resetPassword(params.get("token") ?? "", password);
setMessage("Пароль обновлён. Теперь войдите заново.");
} catch (err) {
setError(errorMessage(err, "Не удалось обновить пароль."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Новый пароль</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(event) => setPassword(event.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>{isSubmitting ? "Сохраняем…" : "Сохранить пароль"}</button>
</form>
</section>
);
}

View File

@@ -1,50 +1,68 @@
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import { PaceTrendChart } from "../components";
import {
formatDistance,
formatRaceDate,
findLatestPersonalRecord,
getRaceCountdownLabel,
getRaceVisual,
getPaceLabel,
isCloseDistance,
parseFinishTimeToSeconds,
parseRaceDate,
splitRacesByDate,
} from "../lib";
const PR_DISTANCES = [5, 10, 21.1, 42.2] as const;
type DashboardHeroStyle = CSSProperties & {
"--dashboard-hero-image"?: string;
};
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "Не удалось загрузить данные dashboard.";
return "Не удалось загрузить данные обзора.";
}
function toCssUrl(value: string): string {
return `url(${JSON.stringify(value)})`;
}
export function DashboardPage(): JSX.Element {
const navigate = useNavigate();
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [chartDistanceKm, setChartDistanceKm] = useState<number>(10);
const [latestDistance, setLatestDistance] = useState<string>("");
const [latestYear, setLatestYear] = useState<string>("");
const [latestPage, setLatestPage] = useState(0);
useEffect(() => {
const ac = new AbortController();
let isMounted = true;
async function loadDashboardData(): Promise<void> {
try {
const items = await getRaces();
if (!isMounted) {
const items = await getRaces(undefined, { signal: ac.signal });
if (!isMounted || ac.signal.aborted) {
return;
}
setRaces(items);
setErrorMessage(null);
} catch (error) {
if (!isMounted) {
if (ac.signal.aborted || !isMounted) {
return;
}
setErrorMessage(getErrorMessage(error));
} finally {
if (isMounted) {
if (isMounted && !ac.signal.aborted) {
setIsLoading(false);
}
}
@@ -53,40 +71,26 @@ export function DashboardPage(): JSX.Element {
void loadDashboardData();
return () => {
isMounted = false;
ac.abort();
};
}, []);
const dashboardMetrics = useMemo(() => {
const { upcoming, past } = splitRacesByDate(races);
const completed = races.filter((race) => race.status === "completed");
const nextRace = upcoming[0] ?? null;
const lastResult = past.find((race) => race.status === "completed") ?? null;
let personalRecord: Race | null = null;
let personalRecordSeconds = Number.POSITIVE_INFINITY;
for (const race of completed) {
const finishSeconds = parseFinishTimeToSeconds(race.finishTime);
if (!finishSeconds) {
continue;
}
const candidate = finishSeconds / race.distanceKm;
if (candidate < personalRecordSeconds) {
personalRecordSeconds = candidate;
personalRecord = race;
}
}
const lastPersonalRecord = findLatestPersonalRecord(races);
const currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => new Date(`${race.date}T00:00:00`).getFullYear() === currentYear);
const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
const seasonCompleted = seasonRaces.filter((race) => race.status === "completed");
return {
nextRace,
lastResult,
personalRecord,
lastPersonalRecord,
seasonTotal: seasonRaces.length,
seasonCompletedCount: seasonCompleted.length,
};
@@ -130,20 +134,41 @@ export function DashboardPage(): JSX.Element {
.filter((race) => race.status === "completed")
.map((race) => ({
id: race.id,
year: new Date(`${race.date}T00:00:00`).getFullYear(),
date: race.date,
year: parseRaceDate(race.date).getFullYear(),
title: race.title,
distance: formatDistance(race.distanceKm),
finishTime: race.finishTime ?? "время не указано",
pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить",
place: race.finishPlace?.trim() ? race.finishPlace : "нет данных",
}))
.sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU"));
.sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
}, [races]);
const latestDistances = useMemo(
() => [...new Set(comparisonRows.map((row) => row.distance))].sort((left, right) => Number(left.split(" ")[0]) - Number(right.split(" ")[0])),
[comparisonRows],
);
const latestYears = useMemo(() => [...new Set(comparisonRows.map((row) => row.year))].sort((a, b) => b - a), [comparisonRows]);
const filteredLatestRows = comparisonRows.filter(
(row) => (!latestDistance || row.distance === latestDistance) && (!latestYear || row.year === Number(latestYear)),
);
const latestPageCount = Math.ceil(filteredLatestRows.length / 5);
const latestRows = filteredLatestRows.slice(latestPage * 5, latestPage * 5 + 5);
const seasonProgress =
dashboardMetrics.seasonTotal > 0
? Math.round((dashboardMetrics.seasonCompletedCount / dashboardMetrics.seasonTotal) * 100)
: 0;
const dashboardHeroVisual = dashboardMetrics.nextRace ? getRaceVisual(dashboardMetrics.nextRace) : null;
const dashboardHeroStyle: DashboardHeroStyle | undefined = dashboardHeroVisual
? { "--dashboard-hero-image": toCssUrl(dashboardHeroVisual.imageSrc) }
: undefined;
if (isLoading) {
return (
<section className="page page--dashboard" aria-busy="true">
<h1 className="page__title">Dashboard</h1>
<h1 className="page__title">Обзор</h1>
<p className="page__subtitle">Загружаем ваши старты...</p>
</section>
);
@@ -152,7 +177,7 @@ export function DashboardPage(): JSX.Element {
if (errorMessage) {
return (
<section className="page page--dashboard" role="alert">
<h1 className="page__title">Dashboard</h1>
<h1 className="page__title">Обзор</h1>
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
</section>
);
@@ -160,60 +185,122 @@ export function DashboardPage(): JSX.Element {
return (
<section className="page page--dashboard">
<h1 className="page__title">Dashboard</h1>
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p>
<section
className={`dashboard-hero${dashboardHeroVisual ? " dashboard-hero--with-image" : ""}`}
style={dashboardHeroStyle}
aria-label="Обзор сезона"
>
<div className="dashboard-hero__content">
<p className="dashboard-hero__eyebrow">Календарь сезона</p>
<h1 className="dashboard-hero__title">Беговой штаб</h1>
<p className="dashboard-hero__text">
Планируйте старты, держите фокус на ближайшей гонке и сравнивайте прогресс по дистанциям.
</p>
<div className="dashboard-hero__actions">
<Link className="btn btn--primary" to="/races">
Смотреть старты
</Link>
<Link className="btn btn--secondary dashboard-hero__secondary" to="/races/new">
Добавить старт
</Link>
</div>
</div>
<div className="dashboard-hero__panel">
<p className="dashboard-hero__panel-label">Ближайший старт</p>
{dashboardMetrics.nextRace ? (
<Link className="dashboard-hero__race-link" to={`/races/${dashboardMetrics.nextRace.id}`}>
<span className="dashboard-hero__race-title">{dashboardMetrics.nextRace.title}</span>
<span className="dashboard-hero__race-meta">
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)}
</span>
<span className="dashboard-hero__race-countdown">
{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}
</span>
</Link>
) : (
<p className="dashboard-hero__empty">Запланируйте первый старт сезона.</p>
)}
</div>
</section>
<div className="dashboard-grid" aria-label="Ключевые метрики">
<article className="dashboard-card">
<h2 className="dashboard-card__title">Ближайший старт</h2>
<article
className={`dashboard-card dashboard-card--accent-blue${dashboardMetrics.nextRace ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.nextRace ? (
<>
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.nextRace.id}`}
aria-label={`Ближайший старт: ${dashboardMetrics.nextRace.title}`}
>
<h2 className="dashboard-card__title">Ближайший старт</h2>
<p className="dashboard-card__value">{dashboardMetrics.nextRace.title}</p>
<p className="dashboard-card__meta">
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)}
{formatRaceDate(dashboardMetrics.nextRace.date)} ·{" "}
{formatDistance(dashboardMetrics.nextRace.distanceKm)}
</p>
<p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</p>
</>
</Link>
) : (
<p className="dashboard-card__empty">Нет запланированных стартов.</p>
<>
<h2 className="dashboard-card__title">Ближайший старт</h2>
<p className="dashboard-card__empty">Нет запланированных стартов.</p>
</>
)}
</article>
<article className="dashboard-card">
<h2 className="dashboard-card__title">Последний результат</h2>
<article
className={`dashboard-card dashboard-card--accent-coral${dashboardMetrics.lastResult ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastResult ? (
<>
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastResult.id}`}
aria-label={`Последний результат: ${dashboardMetrics.lastResult.title}`}
>
<h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">
{dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)}
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</p>
</>
</Link>
) : (
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
)}
</article>
<article className="dashboard-card">
<h2 className="dashboard-card__title">Личный рекорд</h2>
{dashboardMetrics.personalRecord ? (
<>
<p className="dashboard-card__value">{dashboardMetrics.personalRecord.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">
{dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)}
</p>
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p>
<h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
</>
) : (
<p className="dashboard-card__empty">Недостаточно данных для PR.</p>
)}
</article>
<article className="dashboard-card">
{dashboardMetrics.lastPersonalRecord ? (
<article className="dashboard-card dashboard-card--accent-lime dashboard-card--linked">
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastPersonalRecord.id}`}
aria-label={`Последний личный рекорд: ${dashboardMetrics.lastPersonalRecord.title}`}
>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__value">
{dashboardMetrics.lastPersonalRecord.finishTime ?? "время не указано"}
</p>
<p className="dashboard-card__meta">
{dashboardMetrics.lastPersonalRecord.title} ·{" "}
{formatDistance(dashboardMetrics.lastPersonalRecord.distanceKm)}
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}</p>
</Link>
</article>
) : null}
<article className="dashboard-card dashboard-card--season dashboard-card--accent-violet">
<h2 className="dashboard-card__title">Сезон</h2>
<p className="dashboard-card__value">{dashboardMetrics.seasonTotal}</p>
<p className="dashboard-card__meta">стартов в этом году</p>
<p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p>
<div className="dashboard-card__progress" aria-label={`Сезон завершен на ${seasonProgress}%`}>
<span style={{ width: `${seasonProgress}%` }} />
</div>
</article>
</div>
@@ -242,29 +329,53 @@ export function DashboardPage(): JSX.Element {
</section>
<section className="dashboard-section" aria-label="Личные рекорды по дистанциям">
<h2 className="dashboard-section__title">PR по дистанциям</h2>
<h2 className="dashboard-section__title">Рекорды по дистанциям</h2>
<div className="dashboard-grid dashboard-grid--pr">
{personalRecordsByDistance.map((item) => (
<article key={item.distanceKm} className="dashboard-card">
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<article key={item.distanceKm} className={`dashboard-card${item.bestRace ? " dashboard-card--linked" : ""}`}>
{item.bestRace ? (
<>
<Link
className="dashboard-card__link-surface"
to={`/races/${item.bestRace.id}`}
aria-label={`Рекорд на ${formatDistance(item.distanceKm)}: ${item.bestRace.title}`}
>
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<p className="dashboard-card__value">{item.bestRace.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">{item.bestRace.title}</p>
<p className="dashboard-card__hint">{formatRaceDate(item.bestRace.date)}</p>
</>
</Link>
) : (
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
<>
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
</>
)}
</article>
))}
</div>
</section>
<section className="dashboard-section" aria-label="Сравнение завершённых стартов">
<h2 className="dashboard-section__title">Сравнение стартов</h2>
<section className="dashboard-section" aria-label="Последние завершённые старты">
<h2 className="dashboard-section__title">Последние старты</h2>
{comparisonRows.length > 0 ? (
<div className="comparison-table-wrapper">
<>
<div className="dashboard-latest__filters" aria-label="Фильтры последних стартов">
<label className="races-filter__field">
<span className="races-filter__label">Дистанция</span>
<select className="races-filter__select" value={latestDistance} onChange={(event) => { setLatestDistance(event.target.value); setLatestPage(0); }}>
<option value="">Все дистанции</option>
{latestDistances.map((distance) => <option key={distance} value={distance}>{distance}</option>)}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Год участия</span>
<select className="races-filter__select" value={latestYear} onChange={(event) => { setLatestYear(event.target.value); setLatestPage(0); }}>
<option value="">Все годы</option>
{latestYears.map((year) => <option key={year} value={year}>{year}</option>)}
</select>
</label>
</div>
<div className="comparison-table-wrapper">
<table className="comparison-table">
<thead>
<tr>
@@ -277,8 +388,20 @@ export function DashboardPage(): JSX.Element {
</tr>
</thead>
<tbody>
{comparisonRows.map((row) => (
<tr key={row.id}>
{latestRows.map((row) => (
<tr
key={row.id}
className="comparison-table__row"
tabIndex={0}
onClick={() => navigate(`/races/${row.id}`)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
navigate(`/races/${row.id}`);
}
}}
aria-label={`Открыть старт: ${row.title}`}
>
<td>{row.year}</td>
<td>{row.title}</td>
<td>{row.distance}</td>
@@ -289,9 +412,18 @@ export function DashboardPage(): JSX.Element {
))}
</tbody>
</table>
</div>
</div>
{filteredLatestRows.length === 0 ? <p className="dashboard-card__empty">По выбранным фильтрам стартов нет.</p> : null}
{latestPageCount > 1 ? (
<nav className="dashboard-latest__pagination" aria-label="Страницы последних стартов">
<button className="btn btn--secondary" type="button" disabled={latestPage === 0} onClick={() => setLatestPage((page) => page - 1)}>Назад</button>
<span aria-live="polite">Страница {latestPage + 1} из {latestPageCount}</span>
<button className="btn btn--secondary" type="button" disabled={latestPage === latestPageCount - 1} onClick={() => setLatestPage((page) => page + 1)}>Вперёд</button>
</nav>
) : null}
</>
) : (
<p className="dashboard-card__empty">Нет completed-стартов для сравнения.</p>
<p className="dashboard-card__empty">Нет завершённых стартов для сравнения.</p>
)}
</section>
</section>

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { ApiError, getPublicRaces } from "../api";
import type { PublicRace } from "../api";
import { formatDistance, formatRaceDate, getRaceStatusClassName, getRaceStatusLabel } from "../lib";
export function PublicProfilePage(): JSX.Element {
const { username } = useParams<{ username: string }>();
const [races, setRaces] = useState<PublicRace[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
const ac = new AbortController();
if (!username) {
setError("Страница пользователя не найдена.");
setLoading(false);
return () => ac.abort();
}
void getPublicRaces(username, { signal: ac.signal })
.then((items) => {
if (!ac.signal.aborted) {
setRaces(items);
}
})
.catch((cause) => {
if (!ac.signal.aborted) {
setError(cause instanceof ApiError ? cause.message : "Не удалось загрузить страницу пользователя.");
}
})
.finally(() => {
if (!ac.signal.aborted) {
setLoading(false);
}
});
return () => ac.abort();
}, [username]);
return (
<section className="page page--race-day">
<section className="race-day-hero">
<p className="race-day-hero__eyebrow">Публичная страница</p>
<h1 className="page__title">Календарь стартов</h1>
<p className="page__subtitle">Опубликованные старты пользователя.</p>
</section>
{loading ? <p className="page__subtitle" aria-busy="true">Загружаем</p> : null}
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
{!loading && !error && races.length === 0 ? <p className="page__subtitle">Пока нет опубликованных стартов.</p> : null}
{!loading && !error && races.length > 0 ? (
<ul className="race-day__list">
{races.map((race, index) => (
<li className="race-day__item" key={`${race.date}-${race.title}-${index}`}>
<div className="race-day__body">
<span className="race-day__title">{race.title}</span>
<span className="race-day__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} ·{" "}
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
{race.finishTime ? ` · ${race.finishTime}` : ""}
{race.finishPlace ? ` · ${race.finishPlace}` : ""}
</span>
</div>
</li>
))}
</ul>
) : null}
</section>
);
}

View File

@@ -0,0 +1,170 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import {
formatDistance,
formatRaceDate,
getRaceStatusClassName,
getRaceStatusLabel,
getRaceVisual,
sortByDateAsc,
} from "../lib";
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "Не удалось загрузить старты.";
}
export function RaceDayPage(): JSX.Element {
const { ymd } = useParams<{ ymd: string }>();
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const validYmd = ymd && /^\d{4}-\d{2}-\d{2}$/.test(ymd) ? ymd : null;
const year = validYmd ? parseInt(validYmd.slice(0, 4), 10) : NaN;
useEffect(() => {
if (!validYmd || Number.isNaN(year)) {
setIsLoading(false);
setRaces([]);
return;
}
const ac = new AbortController();
let mounted = true;
async function load(): Promise<void> {
setIsLoading(true);
try {
const items = await getRaces({ year }, { signal: ac.signal });
if (!mounted || ac.signal.aborted) {
return;
}
const forDay = items.filter((r) => r.date.slice(0, 10) === validYmd);
setRaces(sortByDateAsc(forDay));
setErrorMessage(null);
} catch (e) {
if (ac.signal.aborted || !mounted) {
return;
}
setErrorMessage(getErrorMessage(e));
setRaces([]);
} finally {
if (mounted && !ac.signal.aborted) {
setIsLoading(false);
}
}
}
void load();
return () => {
mounted = false;
ac.abort();
};
}, [validYmd, year]);
const heading = useMemo(() => {
if (!validYmd) {
return "Дата не указана";
}
return formatRaceDate(validYmd);
}, [validYmd]);
if (!validYmd) {
return (
<section className="page page--race-day">
<div className="race-day-hero">
<p className="race-day-hero__eyebrow">Страница дня</p>
<h1 className="page__title">Некорректная дата</h1>
<Link className="page-link" to="/races">
Вернуться к календарю стартов
</Link>
</div>
</section>
);
}
return (
<section className="page page--race-day">
<section className="race-day-hero" aria-label="Старты дня">
<Link className="page-link" to="/races">
Календарь стартов
</Link>
<p className="race-day-hero__eyebrow">Старты дня</p>
<h1 className="page__title">{heading}</h1>
<p className="page__subtitle">
{isLoading
? "Загружаем расписание..."
: races.length > 0
? `Запланировано стартов: ${races.length}`
: "Проверьте расписание или добавьте старт на эту дату."}
</p>
</section>
{errorMessage ? (
<p className="page__subtitle page__subtitle--error" role="alert">
{errorMessage}
</p>
) : null}
{isLoading ? (
<p className="page__subtitle" aria-busy="true">
Загружаем
</p>
) : null}
{!isLoading && !errorMessage && races.length === 0 ? (
<p className="page__subtitle">На эту дату стартов нет.</p>
) : null}
{!isLoading && races.length > 0 ? (
<ul className="race-day__list">
{races.map((race) => {
const visual = getRaceVisual(race);
return (
<li key={race.id} className="race-day__item">
<Link className="race-day__link" to={`/races/${race.id}`}>
<img
className={`race-day__image${
visual.imageFit === "contain" ? " race-day__image--contain" : ""
}`}
src={visual.imageSrc}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={(event) => {
event.currentTarget.onerror = null;
event.currentTarget.classList.remove("race-day__image--contain");
event.currentTarget.src = visual.fallbackSrc;
}}
/>
<span className="race-day__body">
<span className="race-day__kicker">{visual.label}</span>
<span className="race-day__title">{race.title}</span>
<span className="race-day__meta">
{formatDistance(race.distanceKm)} ·{" "}
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</span>
</span>
</Link>
</li>
);
})}
</ul>
) : null}
<div className="race-day__actions">
<Link className="btn btn--primary" to={`/races/new?date=${validYmd}`}>
Добавить
</Link>
</div>
</section>
);
}

View File

@@ -1,12 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { ApiError, getRaceById } from "../api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ApiError, deleteRace, getRaceById } from "../api";
import {
formatDistance,
formatRaceDate,
getPaceLabel,
getRaceStatusClassName,
getRaceStatusLabel,
getRaceVisual,
raceNeedsResultEntry,
} from "../lib";
import type { Race } from "../api";
@@ -17,13 +19,47 @@ function getErrorMessage(error: unknown): string {
return "Не удалось загрузить карточку старта.";
}
function DetailItem(props: { label: string; value: string | null | undefined }): JSX.Element | null {
const text = props.value?.trim();
if (!text) {
return null;
}
return (
<div className="race-details-meta__item">
<dt className="race-details-meta__key">{props.label}</dt>
<dd className="race-details-meta__value">{text}</dd>
</div>
);
}
function DetailLink(props: { label: string; url: string | null | undefined }): JSX.Element | null {
const href = props.url?.trim();
if (!href) {
return null;
}
return (
<div className="race-details-meta__item">
<dt className="race-details-meta__key">{props.label}</dt>
<dd className="race-details-meta__value">
<a href={href} target="_blank" rel="noopener noreferrer" className="race-details-meta__link">
{href}
</a>
</dd>
</div>
);
}
export function RaceDetailsPage(): JSX.Element {
const { raceId } = useParams<{ raceId: string }>();
const navigate = useNavigate();
const [race, setRace] = useState<Race | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
useEffect(() => {
const ac = new AbortController();
let isMounted = true;
async function loadRace(): Promise<void> {
@@ -34,19 +70,19 @@ export function RaceDetailsPage(): JSX.Element {
}
try {
const item = await getRaceById(raceId);
if (!isMounted) {
const item = await getRaceById(raceId, { signal: ac.signal });
if (!isMounted || ac.signal.aborted) {
return;
}
setRace(item);
setErrorMessage(null);
} catch (error) {
if (!isMounted) {
if (ac.signal.aborted || !isMounted) {
return;
}
setErrorMessage(getErrorMessage(error));
} finally {
if (isMounted) {
if (isMounted && !ac.signal.aborted) {
setIsLoading(false);
}
}
@@ -55,6 +91,7 @@ export function RaceDetailsPage(): JSX.Element {
void loadRace();
return () => {
isMounted = false;
ac.abort();
};
}, [raceId]);
@@ -65,6 +102,22 @@ export function RaceDetailsPage(): JSX.Element {
return getPaceLabel(race.finishTime, race.distanceKm);
}, [race]);
const handleDelete = useCallback(async () => {
if (!raceId) {
return;
}
setIsDeleting(true);
try {
await deleteRace(raceId);
navigate("/races", { replace: true });
} catch (error) {
setErrorMessage(error instanceof ApiError ? error.message : "Не удалось удалить старт.");
setShowDeleteConfirm(false);
} finally {
setIsDeleting(false);
}
}, [raceId, navigate]);
if (isLoading) {
return (
<section className="page page--race-details" aria-busy="true">
@@ -87,19 +140,88 @@ export function RaceDetailsPage(): JSX.Element {
}
const isCompleted = race.status === "completed";
const visual = getRaceVisual(race);
return (
<section className="page page--race-details">
<div className="race-details-header">
<div className="race-details-header__main">
<section className={`race-details-hero race-details-hero--${visual.variant}`} aria-label="Карточка старта">
<img
className={`race-details-hero__image${
visual.imageFit === "contain" ? " race-details-hero__image--contain" : ""
}`}
src={visual.imageSrc}
alt=""
loading="eager"
referrerPolicy="no-referrer"
onError={(event) => {
event.currentTarget.onerror = null;
event.currentTarget.classList.remove("race-details-hero__image--contain");
event.currentTarget.src = visual.fallbackSrc;
}}
/>
<div className="race-details-hero__shade" aria-hidden="true" />
<div className="race-details-hero__content">
<Link className="race-details-hero__back" to="/races">
Календарь стартов
</Link>
<p className="race-details-hero__eyebrow">{visual.label}</p>
<h1 className="page__title">{race.title}</h1>
<p className="page__subtitle">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</div>
<span className={getRaceStatusClassName(race.status)}>{getRaceStatusLabel(race.status)}</span>
</section>
{raceNeedsResultEntry(race) ? (
<p className="race-details-past-hint" role="status">
Дата старта уже прошла {" "}
<Link className="race-details-past-hint__link" to={`/races/${race.id}/edit`}>
внесите результат или обновите статус
</Link>
.
</p>
) : null}
<div className="race-details-actions">
<Link className="btn btn--primary" to={`/races/${race.id}/edit`}>
Редактировать
</Link>
<button
className="btn btn--danger"
type="button"
onClick={() => setShowDeleteConfirm(true)}
>
Удалить
</button>
</div>
{showDeleteConfirm ? (
<div className="confirm-banner" role="alertdialog" aria-label="Подтверждение удаления">
<p className="confirm-banner__text">Удалить «{race.title}»? Это действие необратимо.</p>
<div className="confirm-banner__actions">
<button
className="btn btn--danger"
type="button"
disabled={isDeleting}
onClick={handleDelete}
>
{isDeleting ? "Удаляем…" : "Да, удалить"}
</button>
<button
className="btn btn--secondary"
type="button"
disabled={isDeleting}
onClick={() => setShowDeleteConfirm(false)}
>
Отмена
</button>
</div>
</div>
) : null}
<div className="race-details-grid">
<article className="race-details-card">
<h2 className="race-details-card__title">Основная информация</h2>
@@ -114,13 +236,18 @@ export function RaceDetailsPage(): JSX.Element {
</div>
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Статус</dt>
<dd className="race-details-meta__value">{getRaceStatusLabel(race.status)}</dd>
<dd className="race-details-meta__value">{getRaceStatusLabel(race.status, race.date)}</dd>
</div>
<DetailLink label="Сайт организатора" url={race.officialUrl} />
<DetailItem label="Время старта" value={race.startTime} />
<DetailItem label="Расписание кластеров" value={race.clusterSchedule} />
<DetailItem label="Выдача номеров" value={race.bibPickup} />
<DetailItem label="Стартовый номер" value={race.bibNumber} />
</dl>
</article>
<article className="race-details-card">
<h2 className="race-details-card__title">Completed-метрики</h2>
<h2 className="race-details-card__title">Результаты</h2>
{isCompleted ? (
<dl className="race-details-meta">
<div className="race-details-meta__item">
@@ -131,16 +258,7 @@ export function RaceDetailsPage(): JSX.Element {
<dt className="race-details-meta__key">Темп</dt>
<dd className="race-details-meta__value">{paceLabel ?? "не удалось вычислить"}</dd>
</div>
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Место</dt>
<dd className="race-details-meta__value">
{race.finishPlace?.trim() ? race.finishPlace : "не указано"}
</dd>
</div>
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Стартовый номер</dt>
<dd className="race-details-meta__value">{race.bibNumber ?? "не указан"}</dd>
</div>
<DetailItem label="Место" value={race.finishPlace} />
</dl>
) : (
<p className="race-details-card__empty">

View File

@@ -0,0 +1,561 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, createRace, getRaceById, updateRace } from "../api";
import type { CreateRacePayload, Race, RaceStatus } from "../api";
import { isRaceDateInPast } from "../lib";
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[«»"]/g, "")
.replace(/[^a-zа-яё0-9]+/gi, "-")
.replace(/(^-|-$)/g, "")
.substring(0, 60);
}
function generateId(date: string, title: string): string {
return `${date}-${slugify(title)}`;
}
const STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "Не указан" },
{ value: "planned", label: "Планирую" },
{ value: "registered", label: "Зарегистрирован" },
{ value: "completed", label: "Пробежал" },
];
interface FormData {
date: string;
title: string;
distanceKm: string;
status: string;
officialUrl: string;
coverImageUrl: string;
startTime: string;
clusterSchedule: string;
bibPickup: string;
bibNumber: string;
finishPlace: string;
finishPlaceTotal: string;
originalFinishPlace: string;
isFinishPlaceEdited: boolean;
finishHours: string;
finishMinutes: string;
finishSeconds: string;
notes: string;
}
const EMPTY_FORM: FormData = {
date: "",
title: "",
distanceKm: "",
status: "planned",
officialUrl: "",
coverImageUrl: "",
startTime: "",
clusterSchedule: "",
bibPickup: "",
bibNumber: "",
finishPlace: "",
finishPlaceTotal: "",
originalFinishPlace: "",
isFinishPlaceEdited: false,
finishHours: "",
finishMinutes: "",
finishSeconds: "",
notes: "",
};
function raceToFormData(race: Race): FormData {
const dateValue = race.date.length >= 10 ? race.date.slice(0, 10) : race.date;
return {
date: dateValue,
title: race.title,
distanceKm: String(race.distanceKm),
status: race.status ?? "",
officialUrl: race.officialUrl ?? "",
coverImageUrl: race.coverImageUrl ?? "",
startTime: race.startTime ?? "",
clusterSchedule: race.clusterSchedule ?? "",
bibPickup: race.bibPickup ?? "",
bibNumber: race.bibNumber ?? "",
...finishTimeToFormData(race.finishTime),
...finishPlaceToFormData(race.finishPlace),
notes: race.notes ?? "",
};
}
function finishTimeToFormData(
value: string | null,
): Pick<FormData, "finishHours" | "finishMinutes" | "finishSeconds"> {
const parts = value?.trim().split(":") ?? [];
if (parts.length === 3) {
return { finishHours: parts[0], finishMinutes: parts[1], finishSeconds: parts[2] };
}
if (parts.length === 2) {
return { finishHours: "", finishMinutes: parts[0], finishSeconds: parts[1] };
}
return { finishHours: "", finishMinutes: "", finishSeconds: parts[0] ?? "" };
}
function finishPlaceToFormData(
value: string | null,
): Pick<FormData, "finishPlace" | "finishPlaceTotal" | "originalFinishPlace" | "isFinishPlaceEdited"> {
const originalFinishPlace = value ?? "";
const [finishPlace = "", finishPlaceTotal = ""] = originalFinishPlace.split("/", 2);
return { finishPlace, finishPlaceTotal, originalFinishPlace, isFinishPlaceEdited: false };
}
function emptyToNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
function parseNonNegativeInteger(value: string): number | null {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
return Number(trimmed);
}
function getFinishTime(form: FormData): { value: string | null; error: string | null } {
const hasValue = [form.finishHours, form.finishMinutes, form.finishSeconds].some((value) => value.trim() !== "");
if (!hasValue) {
return { value: null, error: null };
}
const hours = form.finishHours.trim() === "" ? null : parseNonNegativeInteger(form.finishHours);
const minutes = form.finishMinutes.trim() === "" ? 0 : parseNonNegativeInteger(form.finishMinutes);
const seconds = form.finishSeconds.trim() === "" ? 0 : parseNonNegativeInteger(form.finishSeconds);
if (hours === null && form.finishHours.trim() !== "") {
return { value: null, error: "Часы должны быть целым числом не меньше 0." };
}
if (minutes === null || seconds === null || minutes > 59 || seconds > 59) {
return { value: null, error: "Минуты и секунды должны быть целыми числами от 0 до 59." };
}
const pad = (value: number) => String(value).padStart(2, "0");
if (hours !== null) {
return { value: `${hours}:${pad(minutes)}:${pad(seconds)}`, error: null };
}
if (form.finishMinutes.trim() !== "") {
return { value: `${pad(minutes)}:${pad(seconds)}`, error: null };
}
return { value: pad(seconds), error: null };
}
function getFinishPlace(form: FormData): { value: string | null; error: string | null } {
if (!form.isFinishPlaceEdited) {
return { value: emptyToNull(form.originalFinishPlace), error: null };
}
if (form.finishPlace.trim() === "" && form.finishPlaceTotal.trim() === "") {
return { value: null, error: null };
}
const place = parseNonNegativeInteger(form.finishPlace);
const total = form.finishPlaceTotal.trim() === "" ? null : parseNonNegativeInteger(form.finishPlaceTotal);
if (place === null || place < 1 || (total !== null && (total < 1 || place > total))) {
return { value: null, error: "Место должно быть не меньше 1 и не больше числа участников." };
}
return { value: total === null ? String(place) : `${place}/${total}`, error: null };
}
function validateForm(form: FormData): string[] {
const errors: string[] = [];
if (!form.date.trim()) {
errors.push("Дата обязательна.");
}
if (!form.title.trim()) {
errors.push("Название обязательно.");
}
const km = parseFloat(form.distanceKm);
if (Number.isNaN(km) || km <= 0) {
errors.push("Дистанция должна быть положительным числом.");
}
const finishTime = getFinishTime(form);
const finishPlace = getFinishPlace(form);
if (finishTime.error) {
errors.push(finishTime.error);
}
if (finishPlace.error) {
errors.push(finishPlace.error);
}
return errors;
}
function isRaceDateTodayOrPast(date: string): boolean {
if (!date.trim()) {
return false;
}
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, "0");
const d = String(today.getDate()).padStart(2, "0");
return isRaceDateInPast(date) || date.slice(0, 10) === `${y}-${m}-${d}`;
}
export function RaceFormPage(): JSX.Element {
const { raceId } = useParams<{ raceId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const isEditMode = Boolean(raceId);
const [form, setForm] = useState<FormData>(EMPTY_FORM);
const [isLoading, setIsLoading] = useState<boolean>(isEditMode);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
useEffect(() => {
if (!raceId) {
return;
}
let isMounted = true;
async function loadRace(): Promise<void> {
try {
const race = await getRaceById(raceId!);
if (!isMounted) {
return;
}
setForm(raceToFormData(race));
setErrorMessage(null);
} catch (error) {
if (!isMounted) {
return;
}
setErrorMessage(error instanceof ApiError ? error.message : "Не удалось загрузить данные старта.");
} finally {
if (isMounted) {
setIsLoading(false);
}
}
}
void loadRace();
return () => {
isMounted = false;
};
}, [raceId]);
useEffect(() => {
if (isEditMode) {
return;
}
const d = searchParams.get("date");
if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) {
setForm((prev) => (prev.date === d ? prev : { ...prev, date: d }));
}
}, [isEditMode, searchParams]);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = event.target;
setForm((prev) => {
const next = { ...prev, [name]: value };
return next;
});
},
[],
);
const handleSubmit = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
setErrorMessage(null);
const errors = validateForm(form);
setValidationErrors(errors);
if (errors.length > 0) {
return;
}
setIsSaving(true);
try {
const finishTime = getFinishTime(form);
const finishPlace = getFinishPlace(form);
let statusValue: RaceStatus | null =
form.status === "planned" || form.status === "registered" || form.status === "completed"
? form.status
: null;
if (finishTime.value) {
statusValue = "completed";
}
const payload: Omit<CreateRacePayload, "slug"> = {
date: form.date.trim(),
title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
bibNumber: emptyToNull(form.bibNumber),
finishTime: finishTime.value,
finishPlace: finishPlace.value,
notes: emptyToNull(form.notes),
};
if (isEditMode && raceId) {
await updateRace(raceId, payload);
navigate(`/races/${raceId}`);
} else {
const slug = generateId(form.date.trim(), form.title.trim());
const created = await createRace({ ...payload, slug });
navigate(`/races/${created.id}`);
}
} catch (error) {
if (error instanceof ApiError) {
setErrorMessage(error.details.length > 0 ? error.details.join("; ") : error.message);
} else {
setErrorMessage("Произошла ошибка при сохранении.");
}
} finally {
setIsSaving(false);
}
},
[form, isEditMode, raceId, navigate],
);
const hideOrgScheduleFields = isEditMode && isRaceDateInPast(form.date);
const showResultFields = isRaceDateTodayOrPast(form.date);
const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт";
if (isLoading) {
return (
<section className="page page--race-form" aria-busy="true">
<h1 className="page__title">{pageTitle}</h1>
<p className="page__subtitle">Загружаем данные...</p>
</section>
);
}
return (
<section className="page page--race-form">
<h1 className="page__title">{pageTitle}</h1>
{errorMessage ? (
<p className="page__subtitle page__subtitle--error" role="alert">{errorMessage}</p>
) : null}
{validationErrors.length > 0 ? (
<ul className="form-errors" role="alert">
{validationErrors.map((msg) => (
<li key={msg} className="form-errors__item">{msg}</li>
))}
</ul>
) : null}
<form className="race-form" onSubmit={handleSubmit} noValidate>
<fieldset className="race-form__group">
<legend className="race-form__legend">Основная информация</legend>
<label className="race-form__field">
<span className="race-form__label">Дата *</span>
<input
className="race-form__input"
type="date"
name="date"
value={form.date}
onChange={handleChange}
required
/>
</label>
<label className="race-form__field">
<span className="race-form__label">Название *</span>
<input
className="race-form__input"
type="text"
name="title"
value={form.title}
onChange={handleChange}
required
placeholder="Казанский марафон"
/>
</label>
<label className="race-form__field">
<input
className="race-form__input"
type="number"
name="distanceKm"
value={form.distanceKm}
onChange={handleChange}
required
min="0.1"
step="0.001"
placeholder="21.1"
/>
</label>
<label className="race-form__field">
<span className="race-form__label">Статус</span>
<select
className="race-form__input"
name="status"
value={form.status}
onChange={handleChange}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</label>
</fieldset>
<fieldset className="race-form__group">
<legend className="race-form__legend">Организация</legend>
{hideOrgScheduleFields ? null : (
<label className="race-form__field">
<span className="race-form__label">Сайт организатора</span>
<input
className="race-form__input"
type="url"
name="officialUrl"
value={form.officialUrl}
onChange={handleChange}
placeholder="https://…"
/>
</label>
)}
<label className="race-form__field">
<span className="race-form__label">URL обложки</span>
<input
className="race-form__input"
type="url"
name="coverImageUrl"
value={form.coverImageUrl}
onChange={handleChange}
placeholder="https://…"
/>
</label>
{hideOrgScheduleFields ? null : (
<label className="race-form__field">
<span className="race-form__label">Время старта</span>
<input
className="race-form__input"
type="time"
step="1"
name="startTime"
value={form.startTime}
onChange={handleChange}
/>
</label>
)}
{hideOrgScheduleFields ? null : (
<label className="race-form__field">
<span className="race-form__label">Расписание кластеров</span>
<input
className="race-form__input"
type="text"
name="clusterSchedule"
value={form.clusterSchedule}
onChange={handleChange}
/>
</label>
)}
{hideOrgScheduleFields ? null : (
<label className="race-form__field">
<span className="race-form__label">Выдача номеров</span>
<input
className="race-form__input"
type="text"
name="bibPickup"
value={form.bibPickup}
onChange={handleChange}
/>
</label>
)}
<label className="race-form__field">
<span className="race-form__label">Стартовый номер</span>
<input
className="race-form__input"
type="text"
name="bibNumber"
value={form.bibNumber}
onChange={handleChange}
placeholder="1234"
/>
</label>
</fieldset>
{showResultFields ? (
<fieldset className="race-form__group">
<legend className="race-form__legend">Результаты</legend>
<div className="race-form__field">
<span className="race-form__label">Финишное время</span>
<div className="race-form__result-fields">
<label className="race-form__result-field">
<span className="race-form__result-field-label">Часы</span>
<input className="race-form__input" type="number" name="finishHours" value={form.finishHours} onChange={handleChange} min="0" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Минуты</span>
<input className="race-form__input" type="number" name="finishMinutes" value={form.finishMinutes} onChange={handleChange} min="0" max="59" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Секунды</span>
<input className="race-form__input" type="number" name="finishSeconds" value={form.finishSeconds} onChange={handleChange} min="0" max="59" step="1" inputMode="numeric" />
</label>
</div>
</div>
<div className="race-form__field">
<span className="race-form__label">Место на финише</span>
<div className="race-form__result-fields">
<label className="race-form__result-field">
<span className="race-form__result-field-label">Место</span>
<input className="race-form__input" type="number" name="finishPlace" value={form.finishPlace} onChange={(event) => setForm((prev) => ({ ...prev, finishPlace: event.target.value, isFinishPlaceEdited: true }))} min="1" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Всего участников</span>
<input className="race-form__input" type="number" name="finishPlaceTotal" value={form.finishPlaceTotal} onChange={(event) => setForm((prev) => ({ ...prev, finishPlaceTotal: event.target.value, isFinishPlaceEdited: true }))} min="1" step="1" inputMode="numeric" />
</label>
</div>
</div>
</fieldset>
) : null}
<fieldset className="race-form__group">
<legend className="race-form__legend">Дополнительно</legend>
<label className="race-form__field">
<span className="race-form__label">Заметки</span>
<textarea
className="race-form__input race-form__input--textarea"
name="notes"
value={form.notes}
onChange={handleChange}
rows={4}
/>
</label>
</fieldset>
<div className="race-form__actions">
<button className="btn btn--primary" type="submit" disabled={isSaving}>
{isSaving ? "Сохраняем…" : isEditMode ? "Сохранить" : "Создать"}
</button>
<Link
className="btn btn--secondary"
to={isEditMode && raceId ? `/races/${raceId}` : "/races"}
>
Отмена
</Link>
</div>
</form>
</section>
);
}

View File

@@ -1,13 +1,17 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { Race, RacesQuery } from "../api";
import { ApiError, getRaces } from "../api";
import { RacesCalendar } from "../components/RacesCalendar";
import {
formatDistance,
formatRaceDate,
getRaceVisual,
getRaceStatusClassName,
getRaceStatusLabel,
splitRacesByDate,
parseRaceDate,
sortByDateAsc,
sortByDateDesc,
} from "../lib";
const MONTH_OPTIONS: { value: string; label: string }[] = [
@@ -26,6 +30,11 @@ const MONTH_OPTIONS: { value: string; label: string }[] = [
{ value: "12", label: "Декабрь" },
];
const VIEW_STORAGE_KEY = "races-view-mode";
type ViewMode = "list" | "calendar";
type RaceListTab = "upcoming" | "completed";
function yearSelectOptions(): number[] {
const current = new Date().getFullYear();
const start = current - 2;
@@ -44,29 +53,77 @@ function getErrorMessage(error: unknown): string {
return "Не удалось загрузить календарь стартов.";
}
function RaceList(props: { title: string; races: Race[] }): JSX.Element {
const { title, races } = props;
function readInitialViewMode(): ViewMode {
try {
const v = sessionStorage.getItem(VIEW_STORAGE_KEY);
return v === "calendar" ? "calendar" : "list";
} catch {
return "list";
}
}
function RaceList(props: { title: string; races: Race[]; variant: RaceListTab }): JSX.Element {
const { title, races, variant } = props;
return (
<section className="race-list" aria-label={title}>
<section className={`race-list race-list--${variant}`} aria-label={title}>
<h2 className="race-list__title">{title}</h2>
{races.length > 0 ? (
<ul className="race-list__items">
{races.map((race) => (
<li key={race.id} className="race-card">
<div className="race-card__main">
<p className="race-card__title">
<Link className="race-card__link" to={`/races/${race.id}`}>
{race.title}
</Link>
</p>
<p className="race-card__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
</div>
<span className={getRaceStatusClassName(race.status)}>{getRaceStatusLabel(race.status)}</span>
</li>
))}
{races.map((race) => {
const visual = getRaceVisual(race);
const parsedDate = parseRaceDate(race.date);
const day = parsedDate.toLocaleDateString("ru-RU", { day: "2-digit" });
const month = parsedDate.toLocaleDateString("ru-RU", { month: "short" });
return (
<li key={race.id} className={`race-card race-card--action race-card--poster race-card--${visual.variant}`}>
<Link
className="race-card__link-surface"
to={`/races/${race.id}`}
aria-label={`Старт: ${race.title}`}
>
<div className="race-card__image-wrap">
<img
className={`race-card__image${
visual.imageFit === "contain" ? " race-card__image--contain" : ""
}`}
src={visual.imageSrc}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={(event) => {
event.currentTarget.onerror = null;
event.currentTarget.classList.remove("race-card__image--contain");
event.currentTarget.src = visual.fallbackSrc;
}}
/>
<span className="race-card__date-badge">
<span>{day}</span>
<span>{month}</span>
</span>
</div>
<div className="race-card__content">
<div className="race-card__main">
<p className="race-card__kicker">{visual.label}</p>
<p className="race-card__title">
<span className="race-card__title-text">{race.title}</span>
</p>
<p className="race-card__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
</div>
<div className="race-card__footer">
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
<span className="race-card__cta">Открыть</span>
</div>
</div>
</Link>
</li>
);
})}
</ul>
) : (
<p className="race-list__empty">Пока нет данных в этом разделе.</p>
@@ -81,8 +138,35 @@ export function RacesPage(): JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [yearFilter, setYearFilter] = useState<string>("");
const [monthFilter, setMonthFilter] = useState<string>("");
const [viewMode, setViewMode] = useState<ViewMode>(() => readInitialViewMode());
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
const setViewModePersist = useCallback((mode: ViewMode) => {
setViewMode(mode);
try {
sessionStorage.setItem(VIEW_STORAGE_KEY, mode);
} catch {
/* ignore */
}
}, []);
const handleViewList = useCallback(() => {
setViewModePersist("list");
}, [setViewModePersist]);
const handleViewCalendar = useCallback(() => {
setViewModePersist("calendar");
setYearFilter((prev) => (prev === "" ? String(new Date().getFullYear()) : prev));
}, [setViewModePersist]);
const listQuery = useMemo((): RacesQuery | undefined => {
if (viewMode === "calendar") {
const y = yearFilter !== "" ? parseInt(yearFilter, 10) : new Date().getFullYear();
if (!Number.isNaN(y)) {
return { year: y };
}
return undefined;
}
const q: RacesQuery = {};
if (yearFilter !== "") {
const y = parseInt(yearFilter, 10);
@@ -97,27 +181,36 @@ export function RacesPage(): JSX.Element {
}
}
return Object.keys(q).length > 0 ? q : undefined;
}, [yearFilter, monthFilter]);
}, [viewMode, yearFilter, monthFilter]);
const displayYear = useMemo(() => {
if (yearFilter !== "") {
const y = parseInt(yearFilter, 10);
return Number.isNaN(y) ? new Date().getFullYear() : y;
}
return new Date().getFullYear();
}, [yearFilter]);
useEffect(() => {
const ac = new AbortController();
let isMounted = true;
async function loadRaces(): Promise<void> {
setIsLoading(true);
try {
const items = await getRaces(listQuery);
if (!isMounted) {
const items = await getRaces(listQuery, { signal: ac.signal });
if (!isMounted || ac.signal.aborted) {
return;
}
setRaces(items);
setErrorMessage(null);
} catch (error) {
if (!isMounted) {
if (ac.signal.aborted || !isMounted) {
return;
}
setErrorMessage(getErrorMessage(error));
} finally {
if (isMounted) {
if (isMounted && !ac.signal.aborted) {
setIsLoading(false);
}
}
@@ -126,10 +219,36 @@ export function RacesPage(): JSX.Element {
void loadRaces();
return () => {
isMounted = false;
ac.abort();
};
}, [listQuery]);
const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]);
const { upcoming, completed } = useMemo(
() => ({
upcoming: sortByDateAsc(races.filter((race) => race.status !== "completed")),
completed: sortByDateDesc(races.filter((race) => race.status === "completed")),
}),
[races],
);
const statusMessage = useMemo(() => {
if (errorMessage && !isLoading) {
return errorMessage;
}
if (isLoading) {
return "Загружаем данные...";
}
if (viewMode === "calendar" && monthFilter === "") {
return "Выберите месяц, чтобы увидеть его крупным планом.";
}
return "";
}, [errorMessage, isLoading, monthFilter, viewMode]);
const statusClassName = [
"races-status__message",
!statusMessage ? "races-status__message--empty" : "",
errorMessage && !isLoading ? "races-status__message--error" : "",
]
.filter(Boolean)
.join(" ");
if (errorMessage && races.length === 0 && !isLoading) {
return (
@@ -142,55 +261,117 @@ export function RacesPage(): JSX.Element {
return (
<section className="page page--races">
<h1 className="page__title">Календарь стартов</h1>
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
<section className="races-hero" aria-label="Календарь стартов">
<div className="races-hero__content">
<p className="races-hero__eyebrow">Сезонная афиша</p>
<h1 className="page__title">Календарь стартов</h1>
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
<div className="races-view-toggle" role="group" aria-label="Вид отображения">
<button
type="button"
className={`races-view-toggle__btn${viewMode === "list" ? " races-view-toggle__btn--active" : ""}`}
onClick={handleViewList}
>
Список
</button>
<button
type="button"
className={`races-view-toggle__btn${viewMode === "calendar" ? " races-view-toggle__btn--active" : ""}`}
onClick={handleViewCalendar}
>
Календарь
</button>
</div>
</div>
<div className="races-hero__filters">
<div className="races-filter" role="search" aria-label="Фильтр по дате">
<label className="races-filter__field">
<span className="races-filter__label">Год</span>
<select
className="races-filter__select"
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
onChange={(event) => {
setYearFilter(event.target.value);
}}
>
{viewMode === "list" ? <option value="">Все года</option> : null}
{yearSelectOptions().map((y) => (
<option key={y} value={String(y)}>
{y}
</option>
))}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Месяц</span>
<select
className="races-filter__select"
value={monthFilter}
onChange={(event) => {
setMonthFilter(event.target.value);
}}
>
{MONTH_OPTIONS.map((opt) => (
<option key={opt.value || "all"} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
</div>
</div>
</section>
<div className="races-filter" role="search" aria-label="Фильтр по дате">
<label className="races-filter__field">
<span className="races-filter__label">Год</span>
<select
className="races-filter__select"
value={yearFilter}
onChange={(event) => {
setYearFilter(event.target.value);
}}
>
<option value="">Все года</option>
{yearSelectOptions().map((y) => (
<option key={y} value={String(y)}>
{y}
</option>
))}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Месяц</span>
<select
className="races-filter__select"
value={monthFilter}
onChange={(event) => {
setMonthFilter(event.target.value);
}}
>
{MONTH_OPTIONS.map((opt) => (
<option key={opt.value || "all"} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
</div>
{isLoading ? (
<p className="page__subtitle" aria-busy="true">
Загружаем данные...
<div className="races-status" aria-live="polite">
<p
className={statusClassName}
role={errorMessage && !isLoading ? "alert" : undefined}
aria-busy={isLoading || undefined}
aria-hidden={!statusMessage || undefined}
>
{statusMessage || "\u00a0"}
</p>
) : null}
<div className="race-lists">
<RaceList title="Будущие" races={upcoming} />
<RaceList title="Прошедшие" races={past} />
</div>
{viewMode === "list" ? (
<>
<div className="race-list-tabs" role="tablist" aria-label="Раздел стартов">
<button
type="button"
role="tab"
aria-selected={activeListTab === "upcoming"}
className={`race-list-tabs__btn${activeListTab === "upcoming" ? " race-list-tabs__btn--active" : ""}`}
onClick={() => setActiveListTab("upcoming")}
>
Будущие
<span className="race-list-tabs__count">{upcoming.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={activeListTab === "completed"}
className={`race-list-tabs__btn${activeListTab === "completed" ? " race-list-tabs__btn--active" : ""}`}
onClick={() => setActiveListTab("completed")}
>
Прошедшие
<span className="race-list-tabs__count">{completed.length}</span>
</button>
</div>
<div className={`race-lists race-lists--mobile-${activeListTab}`}>
<RaceList title="Будущие" races={upcoming} variant="upcoming" />
<RaceList title="Завершенные" races={completed} variant="completed" />
</div>
</>
) : (
<div className="races-cal-wrap">
<RacesCalendar
displayYear={displayYear}
monthFilter={monthFilter}
races={races}
onMonthFilterChange={setMonthFilter}
/>
</div>
)}
</section>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,28 @@
:root {
--color-bg: #f3f5f7;
--color-bg: #edf3f6;
--color-bg-deep: #071927;
--color-surface: #ffffff;
--color-text: #13202b;
--color-text-muted: #5d6b77;
--color-accent: #1f7ae0;
--color-border: #dce2e8;
--color-success: #2f9e63;
--color-warning: #c0821f;
--color-error: #cc3a3a;
--color-surface-soft: #f7fafc;
--color-text: #0e1f2d;
--color-text-muted: #647483;
--color-accent: #1168d8;
--color-accent-strong: #0c48a0;
--color-lime: #b9f24a;
--color-coral: #ff6f5e;
--color-violet: #6d5dfc;
--color-border: #d6e1ea;
--color-success: #168657;
--color-warning: #b77716;
--color-error: #c43333;
--font-family-base: "Inter", "Segoe UI", Arial, sans-serif;
--font-size-h1: 2rem;
--font-size-h2: 1.5rem;
--font-size-h1: 2.35rem;
--font-size-h2: 1.35rem;
--font-size-body: 1rem;
--font-size-caption: 0.875rem;
--line-height-base: 1.5;
--line-height-base: 1.45;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
@@ -24,6 +31,10 @@
--space-8: 2rem;
--radius-sm: 0.375rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-md: 0.625rem;
--radius-lg: 0.75rem;
--shadow-card: 0 10px 30px rgba(14, 31, 45, 0.08);
--shadow-card-lift: 0 16px 34px rgba(14, 31, 45, 0.16);
--shadow-hero: 0 22px 60px rgba(7, 25, 39, 0.24);
}

12
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_TURNSTILE_SITE_KEY?: string;
}
interface Window {
turnstile?: {
render(container: HTMLElement, options: { sitekey: string; callback(token: string): void; "expired-callback"(): void }): string;
remove(widgetId: string): void;
};
}

View File

@@ -1 +0,0 @@
{"root":["./src/main.tsx","./src/api/index.ts","./src/app/router.tsx","./src/app/layouts/applayout.tsx","./src/components/index.ts","./src/features/index.ts","./src/lib/index.ts","./src/pages/dashboardpage.tsx","./src/pages/racespage.tsx"],"version":"5.9.3"}

View File

@@ -7,7 +7,9 @@
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"],
"strict": true
"strict": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.cache/tsconfig.node.tsbuildinfo"
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -1,5 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()]
});

View File

@@ -2,5 +2,13 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()]
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
});