Compare commits

...

48 Commits

Author SHA1 Message Date
5122ac9d1b Merge pull request 'feature/registration-auth' (#37) from feature/registration-auth into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #37
2026-05-24 11:36:51 +00:00
Vaka.pro
83fa04ecf2 Merge origin/main into feature/registration-auth
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-05-24 14:34:45 +03:00
Vaka.pro
fb246e2e55 fix: harden authentication security
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #36
2026-05-24 11:13:12 +00:00
Vaka.pro
85aff823fc feat: add mobile race list tabs
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #35
2026-04-27 21:40:55 +00:00
Vaka.pro
f62be600cd fix: remove fallback image from dashboard race hero
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #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
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #33
2026-04-27 20:08:11 +00:00
Vaka.pro
78d0ab5ece fix: tolerate missing race cover image field
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #32
2026-04-27 20:02:08 +00:00
Vaka.pro
00985732ec Merge remote-tracking branch 'origin/main' into feature/race-cover-images
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
# 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
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #31
2026-04-27 11:29:02 +00:00
Anton
f5e16c44b3 feat(frontend): add service favicon
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #30
2026-04-27 11:20:31 +00:00
Anton
42057ddb1c chore: fix versioning
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #29
2026-04-27 11:03:46 +00:00
Anton
f7b611bbbe fix(frontend): prevent calendar loading layout shift
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #28
2026-04-27 09:32:39 +00:00
Anton
dffbb48d99 fix frontend calendar race states
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 12:31:29 +03:00
0b7ad23252 Merge pull request 'chore: resizes images' (#27) from chore/resize-images into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #27
2026-04-22 10:27:38 +00:00
Anton
19e9e59125 chore: resizes images
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #26
2026-04-22 08:48:30 +00:00
Anton
0da7454033 feat(frontend): redesign race dashboard
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #25
2026-04-13 19:59:16 +00:00
Vaka.pro
a581ffaaff fix(frontend): hide calendar popups on empty dates
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #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)
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #23
2026-04-13 19:36:10 +00:00
Vaka.pro
4ea8faf16f fix(frontend): auto-completed on finish time, dashboard links, list/calendar UX
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- 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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #22
2026-04-13 19:09:11 +00:00
Vaka.pro
3c6baa66a1 feat(frontend): race form, start time selects, calendar views, day page
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- 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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #21
2026-04-12 15:56:52 +00:00
Vaka.pro
e033b2c8d5 chore: bump patch versions; remove temp request logging
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #20
2026-04-12 15:43:25 +00:00
Vaka.pro
a4f8c37b84 fix(docker): use unique Compose service name for API upstream
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #19
2026-04-10 19:29:27 +00:00
Vaka.pro
c04dc35075 chore(backend): log Host/Origin and status for request debugging
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #18
2026-04-08 09:19:43 +00:00
Anton
8eaf006906 refactor(api): unify /api contract across frontend, nginx, and backend
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-08 11:59:46 +03:00
9f63b190f1 Merge pull request 'feat: /meta для версии в футере и устойчивый разбор JSON' (#17) from feat/footer-backend-meta into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #17
2026-04-08 07:33:37 +00:00
Anton
83bc603b95 feat: /meta для версии в футере и устойчивый разбор JSON
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #16
2026-04-08 07:21:03 +00:00
Anton
53b9561a54 fix(api): дублировать маршруты под /api и убрать Content-Type у GET
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #15
2026-04-07 22:21:49 +00:00
74 changed files with 6164 additions and 387 deletions

View File

@@ -30,10 +30,37 @@ API_PORT=3001
# Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru # Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru
CORS_ORIGIN=http://localhost:5173 CORS_ORIGIN=http://localhost:5173
# ─── 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 (опционально) ───────────────────────────────── # ─── Версия API (опционально) ─────────────────────────────────
# Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health). # Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health).
# APP_VERSION=1.0.0 # APP_VERSION=1.0.0
# ─── Frontend (Vite, локально из каталога frontend/) ───────── # ─── Frontend (Vite, локально из каталога frontend/) ─────────
# В Docker-образе фронта базовый URL API задаётся при сборке (/api), не из .env. # Браузер всегда ходит на относительный /api; в dev это проксирует Vite.
VITE_API_BASE_URL=http://localhost:3001

View File

@@ -1,12 +1,10 @@
# Сборка из корня монорепо: docker build -f Dockerfile.frontend . # Сборка из корня монорепо: docker build -f Dockerfile.frontend .
# SPA дергает API по префиксу /api (nginx проксирует на сервис backend:3000). # SPA дергает API по префиксу /api (nginx проксирует на сервис runners-calendar-backend:3000).
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
COPY frontend/ ./ COPY frontend/ ./
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build RUN npm run build
FROM nginx:alpine FROM nginx:alpine

View File

@@ -4,20 +4,20 @@
## Переменные окружения ## Переменные окружения
Один шаблон для локальной разработки и для Docker-стека: **[`.env.example`](.env.example)** → скопируйте в **`.env`** в корне репозитория. Один шаблон для локальной разработки и для Docker-стека: `**[.env.example](.env.example)`** → скопируйте в `**.env**` в корне репозитория.
Там же перечислены **`DB_HOST`**, **`DB_PORT`**, **`DB_NAME`**, **`DB_USER`**, **`DB_PASSWORD`** (подключение бэкенда к БД), **`PORT`** / **`API_PORT`**, опционально **`CALENDAR_RUN_MOCK_DB`**, **`CORS_ORIGIN`**, а для локального Vite — **`VITE_API_BASE_URL`**. Там же перечислены `**DB_HOST**`, `**DB_PORT**`, `**DB_NAME**`, `**DB_USER**`, `**DB_PASSWORD**` (подключение бэкенда к БД), `**PORT**` / `**API_PORT**`, опционально `**CALENDAR_RUN_MOCK_DB**` и `**CORS_ORIGIN**`.
## Backend — локально ## Backend — локально
1. `cd backend && npm install` 1. `cd backend && npm install`
2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`). 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`. 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` 4. `cd backend && npm run db:migrate && npm run seed`
5. Dev-режим: `npm run dev` 5. Dev-режим: `npm run dev`
6. Или production: `npm run build && npm start` 6. Или production: `npm run build && npm start`
Без PostgreSQL (только smoke API): в `.env` задайте `CALENDAR_RUN_MOCK_DB=1`; **`db:migrate` и `seed` с mock не использовать**. Без PostgreSQL (только smoke API): в `.env` задайте `CALENDAR_RUN_MOCK_DB=1`; `**db:migrate` и `seed` с mock не использовать**.
## Frontend — локально ## Frontend — локально
@@ -28,21 +28,22 @@ npm install
npm run dev npm run dev
``` ```
Значение `VITE_API_BASE_URL` см. в **корневом** [`.env.example`](.env.example); для dev по умолчанию `http://localhost:3001`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`). Фронт всегда отправляет запросы на относительный префикс `**/api**`. В dev это проксирует Vite на `http://localhost:3001`, в Docker/проде — nginx фронта проксирует на `runners-calendar-backend:3000`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
## Docker: backend + frontend рядом с Postgres ## Docker: backend + frontend рядом с Postgres
Используйте [`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. Используйте `[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.
```bash ```bash
docker compose -f docker-compose.stack.yml up -d --build docker compose -f docker-compose.stack.yml up -d --build
docker compose -f docker-compose.stack.yml exec backend node dist/migrate.js docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/migrate.js
docker compose -f docker-compose.stack.yml exec backend node dist/seed.js docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/seed.js
``` ```
Фронт в браузере обращается к API по префиксу **`/api`** (nginx в образе фронта проксирует на backend). Фронт в браузере обращается к API по префиксу `**/api**` (nginx в образе фронта проксирует на сервис `runners-calendar-backend` в той же сети).
## Документация API и бэкенда ## Документация API и бэкенда
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md) - [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
- [Эксплуатация backend](docs/backend.md) - [Эксплуатация backend](docs/backend.md)

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

@@ -1,23 +1,31 @@
{ {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.0.0", "version": "1.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.0.0", "version": "1.4.1",
"dependencies": { "dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "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": { "devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.12.0", "@types/node": "^22.12.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
@@ -44,7 +52,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -540,6 +547,15 @@
"@noble/hashes": "^1.1.5" "@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": { "node_modules/@tsconfig/node10": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -589,6 +605,16 @@
"@types/node": "*" "@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": { "node_modules/@types/cookiejar": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -651,11 +677,20 @@
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "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": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -773,6 +808,22 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -908,6 +959,25 @@
"node": ">= 0.6" "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": { "node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -949,7 +1019,6 @@
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@epic-web/invariant": "^1.0.0", "@epic-web/invariant": "^1.0.0",
@@ -967,7 +1036,6 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
@@ -1233,6 +1301,24 @@
"url": "https://opencollective.com/express" "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": { "node_modules/fast-safe-stringify": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -1437,6 +1523,15 @@
"node": ">= 0.4" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1475,6 +1570,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1488,7 +1592,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/make-error": { "node_modules/make-error": {
@@ -1582,6 +1685,35 @@
"node": ">= 0.6" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1638,7 +1770,6 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -1655,7 +1786,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.12.0", "pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0", "pg-pool": "^3.13.0",
@@ -1922,7 +2052,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
@@ -1935,7 +2064,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -2207,7 +2335,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2261,7 +2388,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
@@ -2298,6 +2424,15 @@
"engines": { "engines": {
"node": ">=6" "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", "name": "calendar-run-backend",
"version": "1.0.0", "version": "1.4.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
@@ -11,16 +11,24 @@
"test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts" "test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
}, },
"dependencies": { "dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "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": { "devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.12.0", "@types/node": "^22.12.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",

View File

@@ -1,17 +1,60 @@
import express, { Request, Response, NextFunction } from "express"; import express, { Request, Response, NextFunction } from "express";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import { config } from "./config"; import { config } from "./config";
import { loadAuth, requireCsrf } from "./authMiddleware";
import authRouter from "./routes/auth";
import healthRouter from "./routes/health"; import healthRouter from "./routes/health";
import racesRouter from "./routes/races"; import racesRouter from "./routes/races";
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 { export function createApp(): express.Express {
const app = 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(express.json());
app.use(cookieParser(config.session.secret));
app.use(loadAuth);
app.use(requireCsrf);
app.use(healthRouter); app.use("/api", healthRouter);
app.use(racesRouter); app.use("/api", authRouter);
app.use("/api", racesRouter);
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof SyntaxError && "body" in err) { if (err instanceof SyntaxError && "body" in err) {

View File

@@ -0,0 +1,83 @@
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;
};
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();
}

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

@@ -0,0 +1,396 @@
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;
}
interface UserRow {
id: string;
email: string;
password_hash: string;
email_verified_at: Date | string | null;
}
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;
}
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),
};
}
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 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`,
[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`,
[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),
},
};
}
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 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
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),
},
csrfTokenHash: row.csrf_token_hash,
};
}
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,10 +11,46 @@ function requireEnv(name: string): string {
return value; return value;
} }
function optionalEnv(name: string): string | null {
const value = process.env[name]?.trim();
return value ? value : null;
}
function requireEnvUnlessMock(name: string, fallback: string): string {
if (useMockDb) {
return process.env[name]?.trim() || fallback;
}
return requireEnv(name);
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value == null || value.trim() === "") {
return fallback;
}
return value === "1" || value.toLowerCase() === "true";
}
const useMockDb = const useMockDb =
process.env.CALENDAR_RUN_MOCK_DB === "1" || process.env.CALENDAR_RUN_MOCK_DB === "1" ||
process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true"; process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true";
const securityProfile = process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development";
export function resolveTurnstileBypassToken(params: {
rawBypassToken?: string;
securityProfile: string;
useMockDb: boolean;
}): string {
const raw = params.rawBypassToken?.trim() ?? "";
if (raw && params.securityProfile === "production" && !params.useMockDb) {
throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production");
}
if (raw) {
return raw;
}
return params.useMockDb ? "mock-turnstile-token" : "";
}
export const config = { export const config = {
useMockDb, useMockDb,
db: useMockDb db: useMockDb
@@ -35,6 +71,33 @@ export const config = {
apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10), apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10),
/** Одно значение или несколько через запятую (прод: https://домен) */ /** Одно значение или несколько через запятую (прод: https://домен) */
corsOrigin: parseCorsOrigins(), corsOrigin: parseCorsOrigins(),
appBaseUrl: process.env.APP_BASE_URL?.trim() || "http://localhost:5173",
session: {
secret: requireEnvUnlessMock("SESSION_SECRET", "mock-session-secret-change-me"),
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() || (useMockDb ? "mock-turnstile-secret" : ""),
bypassToken: resolveTurnstileBypassToken({
rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN,
securityProfile,
useMockDb,
}),
},
authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10),
securityProfile,
}; };
function parseCorsOrigins(): string | string[] { function parseCorsOrigins(): string | string[] {

View File

@@ -1,4 +1,5 @@
import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg"; import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg";
import crypto from "crypto";
import { config } from "./config"; import { config } from "./config";
import type { RaceRow } from "./mappers/race"; import type { RaceRow } from "./mappers/race";
@@ -19,11 +20,14 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
if (!match) { if (!match) {
return { return {
id: String(params[0] ?? ""), id: String(params[0] ?? ""),
slug: String(params[0] ?? ""),
owner_user_id: null,
race_date: "", race_date: "",
title: "", title: "",
distance_km: "0", distance_km: "0",
status: null, status: null,
official_url: null, official_url: null,
cover_image_url: null,
start_time: null, start_time: null,
cluster_schedule: null, cluster_schedule: null,
bib_pickup: null, bib_pickup: null,
@@ -41,12 +45,15 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
row[col] = params[i]; row[col] = params[i];
}); });
return { return {
id: String(row.id ?? ""), id: String(row.id ?? crypto.randomUUID()),
slug: String(row.slug ?? row.id ?? ""),
owner_user_id: row.owner_user_id != null ? String(row.owner_user_id) : null,
race_date: String(row.race_date ?? ""), race_date: String(row.race_date ?? ""),
title: String(row.title ?? ""), title: String(row.title ?? ""),
distance_km: String(row.distance_km ?? "0"), distance_km: String(row.distance_km ?? "0"),
status: row.status != null ? String(row.status) : null, status: row.status != null ? String(row.status) : null,
official_url: row.official_url != null ? String(row.official_url) : null, official_url: row.official_url != null ? String(row.official_url) : null,
cover_image_url: row.cover_image_url != null ? String(row.cover_image_url) : null,
start_time: row.start_time != null ? String(row.start_time) : null, start_time: row.start_time != null ? String(row.start_time) : null,
cluster_schedule: row.cluster_schedule != null ? String(row.cluster_schedule) : null, cluster_schedule: row.cluster_schedule != null ? String(row.cluster_schedule) : null,
bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null, bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null,
@@ -70,6 +77,14 @@ function createMockPool(): Pool {
}) as QueryResult<T>; }) as QueryResult<T>;
const store = new Map<string, RaceRow>(); const store = new Map<string, RaceRow>();
const users = new Map<string, any>();
const sessions = new Map<string, any>();
const verificationTokens = new Map<string, any>();
const resetTokens = new Map<string, any>();
const appSettings = new Map<string, string>();
const result = <T extends QueryResultRow>(rows: T[], command = "SELECT"): QueryResult<T> =>
({ rows, rowCount: rows.length, command, oid: 0, fields: [] }) as QueryResult<T>;
const mockQuery = async <T extends QueryResultRow>( const mockQuery = async <T extends QueryResultRow>(
text: string, text: string,
@@ -78,8 +93,322 @@ function createMockPool(): Pool {
const sql = text.replace(/\s+/g, " ").trim(); const sql = text.replace(/\s+/g, " ").trim();
const p = params ?? []; const p = params ?? [];
if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK" || sql.includes("pg_advisory_xact_lock")) {
return result<T>([]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM users")) {
return result([{ count: String(users.size) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM sessions")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(sessions.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM email_verification_tokens")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(verificationTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM password_reset_tokens")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(resetTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) {
const email = String(p[0] ?? "");
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
return user ? result([user as T]) : emptyResult();
}
if (sql.includes("SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email))")) {
const email = String(p[0] ?? "");
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
return user ? result([user as T]) : emptyResult();
}
if (sql.includes("INSERT INTO users")) {
const email = String(p[0] ?? "").trim().toLowerCase();
const existing = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
if (existing) {
const err = new Error("duplicate key") as Error & { code?: string };
err.code = "23505";
throw err;
}
const id = crypto.randomUUID();
const row = {
id,
email: String(p[0] ?? ""),
password_hash: String(p[1] ?? ""),
email_verified_at: null,
created_at: new Date(),
updated_at: null,
};
users.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("INSERT INTO email_verification_tokens")) {
const id = crypto.randomUUID();
const row = {
id,
user_id: String(p[0] ?? ""),
token_hash: String(p[1] ?? ""),
expires_at: p[2] ?? new Date(),
used_at: null,
created_at: new Date(),
};
verificationTokens.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE id")) {
const id = String(p[0] ?? "");
const row = verificationTokens.get(id);
if (!row || row.used_at != null) {
return result<T>([], "UPDATE");
}
row.used_at = new Date();
return result([{ id: row.id } as unknown as T], "UPDATE");
}
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? "");
const exceptId = p[1] != null ? String(p[1]) : null;
for (const row of verificationTokens.values()) {
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
row.used_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("FROM email_verification_tokens") && sql.includes("token_hash =")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
return result(
Array.from(verificationTokens.values()).filter(
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
) as T[],
);
}
if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) {
const now = Date.now();
return result(
Array.from(verificationTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
);
}
if (sql.includes("INSERT INTO password_reset_tokens")) {
const id = crypto.randomUUID();
const row = {
id,
user_id: String(p[0] ?? ""),
token_hash: String(p[1] ?? ""),
expires_at: p[2] ?? new Date(),
used_at: null,
created_at: new Date(),
};
resetTokens.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE id")) {
const id = String(p[0] ?? "");
const row = resetTokens.get(id);
if (!row || row.used_at != null) {
return result<T>([], "UPDATE");
}
row.used_at = new Date();
return result([{ id: row.id } as unknown as T], "UPDATE");
}
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? "");
const exceptId = p[1] != null ? String(p[1]) : null;
for (const row of resetTokens.values()) {
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
row.used_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("FROM password_reset_tokens") && sql.includes("token_hash =")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
return result(
Array.from(resetTokens.values()).filter(
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
) as T[],
);
}
if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) {
const now = Date.now();
return result(
Array.from(resetTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
);
}
if (sql.includes("UPDATE users SET email_verified_at")) {
const user = users.get(String(p[0] ?? ""));
if (user) {
user.email_verified_at = user.email_verified_at ?? new Date();
user.updated_at = new Date();
}
return result<T>([], "UPDATE");
}
if (sql.includes("UPDATE users SET password_hash")) {
const user = users.get(String(p[0] ?? ""));
if (user) {
user.password_hash = String(p[1] ?? "");
user.updated_at = new Date();
}
return result<T>([], "UPDATE");
}
if (sql.includes("INSERT INTO sessions")) {
const id = crypto.randomUUID();
const user = users.get(String(p[0] ?? ""));
const row = {
id,
user_id: String(p[0] ?? ""),
token_hash: String(p[1] ?? ""),
csrf_token_hash: String(p[2] ?? ""),
expires_at: p[3] ?? new Date(),
email: user?.email ?? "",
email_verified_at: user?.email_verified_at ?? null,
revoked_at: null,
created_at: new Date(),
last_seen_at: new Date(),
};
sessions.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("FROM sessions s JOIN users u")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
const row = Array.from(sessions.values()).find(
(item) => item.token_hash === tokenHash && !item.revoked_at && new Date(item.expires_at).getTime() > now,
);
if (!row) {
return emptyResult();
}
const user = users.get(row.user_id);
return result([{ ...row, email: user?.email ?? "", email_verified_at: user?.email_verified_at ?? null } as unknown as T]);
}
if (sql.includes("UPDATE sessions SET csrf_token_hash")) {
const tokenHash = String(p[0] ?? "");
const row = Array.from(sessions.values()).find((item) => item.token_hash === tokenHash && !item.revoked_at);
if (row) {
row.csrf_token_hash = String(p[1] ?? "");
row.last_seen_at = new Date();
}
return result<T>(row ? ([{} as unknown as T]) : [], "UPDATE");
}
if (sql.includes("UPDATE sessions SET last_seen_at")) {
return result<T>([], "UPDATE");
}
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE token_hash")) {
const tokenHash = String(p[0] ?? "");
for (const row of sessions.values()) {
if (row.token_hash === tokenHash) {
row.revoked_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? "");
for (const row of sessions.values()) {
if (row.user_id === userId && !row.revoked_at) {
row.revoked_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("DELETE FROM sessions WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of sessions.entries()) {
const revokedAt = row.revoked_at ? new Date(row.revoked_at).getTime() : null;
const staleRevoked = revokedAt != null && revokedAt < now - 30 * 24 * 60 * 60 * 1000;
if (new Date(row.expires_at).getTime() <= now || staleRevoked) {
sessions.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("DELETE FROM email_verification_tokens WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of verificationTokens.entries()) {
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
verificationTokens.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("DELETE FROM password_reset_tokens WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of resetTokens.entries()) {
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
resetTokens.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("SELECT value FROM app_settings")) {
const value = appSettings.get("orphan_races_claimed_by_user_id");
return value ? result([{ value } as unknown as T]) : emptyResult();
}
if (sql.includes("INSERT INTO app_settings")) {
appSettings.set("orphan_races_claimed_by_user_id", String(p[0] ?? ""));
return result<T>([], "INSERT");
}
if (sql.includes("UPDATE races SET owner_user_id")) {
const userId = String(p[0] ?? "");
for (const race of store.values()) {
if (!race.owner_user_id) {
race.owner_user_id = userId;
race.updated_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) { if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
const row = mockRowFromInsert(text, p); const row = mockRowFromInsert(text, p);
const conflict = Array.from(store.values()).find(
(item) => item.owner_user_id && item.owner_user_id === row.owner_user_id && item.slug === row.slug,
);
if (conflict) {
const err = new Error("duplicate key") as Error & { code?: string };
err.code = "23505";
throw err;
}
store.set(row.id, row); store.set(row.id, row);
return { return {
rows: [row as unknown as T], rows: [row as unknown as T],
@@ -92,7 +421,12 @@ function createMockPool(): Pool {
if (sql.includes("DELETE FROM races")) { if (sql.includes("DELETE FROM races")) {
const id = String(p[0] ?? ""); const id = String(p[0] ?? "");
const existed = store.delete(id); const ownerId = p[1] != null ? String(p[1]) : null;
const existing = store.get(id);
const existed = Boolean(existing && (!ownerId || existing.owner_user_id === ownerId));
if (existed) {
store.delete(id);
}
return { return {
rows: [], rows: [],
rowCount: existed ? 1 : 0, rowCount: existed ? 1 : 0,
@@ -103,12 +437,25 @@ function createMockPool(): Pool {
} }
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) { if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
const id = String(p[p.length - 1] ?? ""); const id = String(p[p.length - 2] ?? p[p.length - 1] ?? "");
const ownerId = p[p.length - 1] != null ? String(p[p.length - 1]) : null;
const existing = store.get(id); const existing = store.get(id);
if (!existing) { if (!existing || (ownerId && existing.owner_user_id !== ownerId)) {
return emptyResult(); return emptyResult();
} }
const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/);
const updated = { ...existing, updated_at: new Date() }; const updated = { ...existing, updated_at: new Date() };
const setColumns =
setMatch?.[1]
.split(",")
.map((part) => part.trim())
.filter((part) => !part.startsWith("updated_at"))
.map((part) => part.split("=")[0]?.trim())
.filter((col): col is string => Boolean(col)) ?? [];
setColumns.forEach((col, index) => {
(updated as unknown as Record<string, unknown>)[col] = p[index] ?? null;
});
store.set(id, updated); store.set(id, updated);
return { return {
rows: [updated as unknown as T], rows: [updated as unknown as T],
@@ -121,14 +468,18 @@ function createMockPool(): Pool {
if (sql.includes("SELECT * FROM races WHERE id =")) { if (sql.includes("SELECT * FROM races WHERE id =")) {
const id = String(p[0] ?? ""); const id = String(p[0] ?? "");
const ownerId = p[1] != null ? String(p[1]) : null;
const row = store.get(id); const row = store.get(id);
return row return row && (!ownerId || row.owner_user_id === ownerId)
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T> ? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
: emptyResult(); : emptyResult();
} }
if (sql.includes("SELECT * FROM races")) { if (sql.includes("SELECT * FROM races")) {
const rows = Array.from(store.values()); const ownerParam = p.find((value) => typeof value === "string" && /^[0-9a-f-]{36}$/i.test(value));
const rows = ownerParam
? Array.from(store.values()).filter((row) => row.owner_user_id === ownerParam)
: Array.from(store.values());
return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>; return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>;
} }
@@ -138,9 +489,10 @@ function createMockPool(): Pool {
const mockPool = { const mockPool = {
query: mockQuery, query: mockQuery,
connect: async () => { connect: async () => {
throw new Error( return {
"CALENDAR_RUN_MOCK_DB is enabled: migrate/seed require a real database; unset CALENDAR_RUN_MOCK_DB and configure DB_*.", query: mockQuery,
); release() {},
};
}, },
end: async () => {}, end: async () => {},
on() { on() {

View File

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

@@ -4,11 +4,14 @@
*/ */
export interface RaceRow { export interface RaceRow {
id: string; id: string;
slug: string;
owner_user_id: string | null;
race_date: string | Date; race_date: string | Date;
title: string; title: string;
distance_km: string; distance_km: string;
status: string | null; status: string | null;
official_url: string | null; official_url: string | null;
cover_image_url: string | null;
start_time: string | null; start_time: string | null;
cluster_schedule: string | null; cluster_schedule: string | null;
bib_pickup: string | null; bib_pickup: string | null;
@@ -23,11 +26,13 @@ export interface RaceRow {
/** API shape (camelCase). */ /** API shape (camelCase). */
export interface RaceDto { export interface RaceDto {
id: string; id: string;
slug: string;
date: string; date: string;
title: string; title: string;
distanceKm: number; distanceKm: number;
status: string | null; status: string | null;
officialUrl: string | null; officialUrl: string | null;
coverImageUrl: string | null;
startTime: string | null; startTime: string | null;
clusterSchedule: string | null; clusterSchedule: string | null;
bibPickup: string | null; bibPickup: string | null;
@@ -59,11 +64,13 @@ function raceDateToApiValue(value: string | Date): string {
export function rowToDto(row: RaceRow): RaceDto { export function rowToDto(row: RaceRow): RaceDto {
return { return {
id: row.id, id: row.id,
slug: row.slug,
date: raceDateToApiValue(row.race_date), date: raceDateToApiValue(row.race_date),
title: row.title, title: row.title,
distanceKm: parseFloat(row.distance_km), distanceKm: parseFloat(row.distance_km),
status: row.status, status: row.status,
officialUrl: row.official_url, officialUrl: row.official_url,
coverImageUrl: row.cover_image_url ?? null,
startTime: row.start_time, startTime: row.start_time,
clusterSchedule: row.cluster_schedule, clusterSchedule: row.cluster_schedule,
bibPickup: row.bib_pickup, bibPickup: row.bib_pickup,
@@ -78,11 +85,13 @@ export function rowToDto(row: RaceRow): RaceDto {
/** Map incoming camelCase body fields to snake_case column names. */ /** Map incoming camelCase body fields to snake_case column names. */
const FIELD_MAP: Record<string, string> = { const FIELD_MAP: Record<string, string> = {
slug: "slug",
date: "race_date", date: "race_date",
title: "title", title: "title",
distanceKm: "distance_km", distanceKm: "distance_km",
status: "status", status: "status",
officialUrl: "official_url", officialUrl: "official_url",
coverImageUrl: "cover_image_url",
startTime: "start_time", startTime: "start_time",
clusterSchedule: "cluster_schedule", clusterSchedule: "cluster_schedule",
bibPickup: "bib_pickup", bibPickup: "bib_pickup",

View File

@@ -0,0 +1,103 @@
const IMAGE_META_KEYS = new Set([
"og:image",
"og:image:url",
"twitter:image",
"twitter:image:src",
]);
const FETCH_TIMEOUT_MS = 5_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 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 {
const response = await fetch(normalizedUrl, {
redirect: "follow",
signal: controller.signal,
});
if (!response.ok) {
return null;
}
const html = await response.text();
return extractRaceCoverImageFromHtml(html, response.url || normalizedUrl);
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}

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

@@ -0,0 +1,197 @@
import { Router, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import { z } from "zod";
import {
loginUser,
registerUser,
requestPasswordReset,
resendVerification,
resetPassword,
rotateCsrf,
revokeSession,
verifyEmailToken,
} from "../authService";
import { clearSessionCookie, 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 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"),
});
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.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

@@ -8,6 +8,11 @@ router.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok", version: getBackendVersion() }); 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) => { router.get("/ready", async (_req: Request, res: Response) => {
const dbOk = await checkDbConnection(); const dbOk = await checkDbConnection();
if (dbOk) { if (dbOk) {

View File

@@ -1,8 +1,11 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { pool } from "../db"; import { pool } from "../db";
import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race"; import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
import { extractRaceCoverImage } from "../raceCoverImage";
import { requireAuth } from "../authMiddleware";
const router = Router(); const router = Router();
router.use(requireAuth);
type ValidationErrorBody = { type ValidationErrorBody = {
error: "validation_error"; error: "validation_error";
@@ -68,6 +71,9 @@ router.get("/races", async (req: Request, res: Response) => {
const params: unknown[] = []; const params: unknown[] = [];
let idx = 1; let idx = 1;
conditions.push(`owner_user_id = $${idx++}`);
params.push(req.auth!.user.id);
if (yearResult.value != null) { if (yearResult.value != null) {
conditions.push(`EXTRACT(YEAR FROM race_date) = $${idx++}`); conditions.push(`EXTRACT(YEAR FROM race_date) = $${idx++}`);
params.push(yearResult.value); params.push(yearResult.value);
@@ -93,8 +99,8 @@ router.get("/races", async (req: Request, res: Response) => {
router.get("/races/:id", async (req: Request, res: Response) => { router.get("/races/:id", async (req: Request, res: Response) => {
try { try {
const { rows } = await pool.query<RaceRow>( const { rows } = await pool.query<RaceRow>(
"SELECT * FROM races WHERE id = $1", "SELECT * FROM races WHERE id = $1 AND owner_user_id = $2",
[req.params.id], [req.params.id, req.auth!.user.id],
); );
if (rows.length === 0) { if (rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] }); res.status(404).json({ error: "not_found", details: ["Race not found"] });
@@ -112,14 +118,28 @@ router.get("/races/:id", async (req: Request, res: Response) => {
router.post("/races", async (req: Request, res: Response) => { router.post("/races", async (req: Request, res: Response) => {
const body = req.body; const body = req.body;
if (!body.id || !body.date || !body.title || body.distanceKm == null) { const slug = typeof body.slug === "string" && body.slug.trim() ? body.slug.trim() : body.id;
validationError(res, ["Fields id, date, title, distanceKm are required"]);
if (!slug || !body.date || !body.title || body.distanceKm == null) {
validationError(res, ["Fields slug, date, title, distanceKm are required"]);
return; return;
} }
const { columns, values } = bodyToColumns(body); const payload = { ...body, slug };
columns.unshift("id"); const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "";
values.unshift(body.id); const hasOfficialUrl = typeof payload.officialUrl === "string" && payload.officialUrl.trim() !== "";
if (!hasManualCover && hasOfficialUrl) {
payload.coverImageUrl = await extractRaceCoverImage(payload.officialUrl);
}
const { columns, values } = bodyToColumns(payload);
columns.unshift("owner_user_id");
values.unshift(req.auth!.user.id);
if (!columns.includes("slug")) {
columns.push("slug");
values.push(slug);
}
const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`; const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
@@ -131,7 +151,7 @@ router.post("/races", async (req: Request, res: Response) => {
if (err.code === "23505") { if (err.code === "23505") {
res.status(409).json({ res.status(409).json({
error: "conflict", error: "conflict",
details: ["Race with this id already exists"], details: ["Race with this slug already exists"],
}); });
return; return;
} }
@@ -153,7 +173,8 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
const sets = columns.map((col, i) => `${col} = $${i + 1}`); const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`); sets.push(`updated_at = NOW()`);
values.push(req.params.id); values.push(req.params.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length} RETURNING *`; 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 { try {
const { rows } = await pool.query<RaceRow>(sql, values); const { rows } = await pool.query<RaceRow>(sql, values);
@@ -173,8 +194,8 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
router.delete("/races/:id", async (req: Request, res: Response) => { router.delete("/races/:id", async (req: Request, res: Response) => {
try { try {
const { rowCount } = await pool.query( const { rowCount } = await pool.query(
"DELETE FROM races WHERE id = $1", "DELETE FROM races WHERE id = $1 AND owner_user_id = $2",
[req.params.id], [req.params.id, req.auth!.user.id],
); );
if (rowCount === 0) { if (rowCount === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] }); res.status(404).json({ error: "not_found", details: ["Race not found"] });

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,6 +24,31 @@ function makeId(date: string, title: string): string {
return `${date}-${slugify(title)}`; 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"; const CSV_NAME = "races_2026_calendar.csv";
function resolveCsvPath(): string | null { function resolveCsvPath(): string | null {
@@ -56,23 +81,28 @@ async function seed() {
const client = await pool.connect(); const client = await pool.connect();
try { try {
const ownerUserId = await resolveSeedOwnerUserId(client);
for (const row of records) { 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); const distanceKm = parseFloat(row.distance_km);
await client.query( if (ownerUserId) {
`INSERT INTO races (id, race_date, title, distance_km, status) await client.query(
VALUES ($1, $2, $3, $4, $5) `INSERT INTO races (slug, owner_user_id, race_date, title, distance_km, status, source)
ON CONFLICT (id) DO UPDATE SET VALUES ($1, $2, $3, $4, $5, $6, 'seed')
race_date = EXCLUDED.race_date, ON CONFLICT DO NOTHING`,
title = EXCLUDED.title, [slug, ownerUserId, row.date, row.event, distanceKm, "planned"],
distance_km = EXCLUDED.distance_km, );
status = EXCLUDED.status, } else {
updated_at = NOW()`, await client.query(
[id, row.date, row.event, distanceKm, "planned"], `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."); 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;
}
}

View File

@@ -1,41 +1,405 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import request from "supertest"; 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 { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
import { hashPassword, normalizeEmail } from "../src/security";
const app = createApp(); const app = createApp();
let userCounter = 0;
test("GET /health returns ok", async () => { async function authAgent() {
const res = await request(app).get("/health").expect(200); 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 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(res.body.status, "ok");
assert.equal(typeof res.body.version, "string"); assert.equal(typeof res.body.version, "string");
assert.ok(res.body.version.length > 0); assert.ok(res.body.version.length > 0);
}); });
test("GET /ready succeeds with mock database", async () => { test("GET /api/meta returns version for UI footer", async () => {
const res = await request(app).get("/ready").expect(200); 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 mock database", async () => {
const res = await request(app).get("/api/ready").expect(200);
assert.equal(res.body.status, "ready"); assert.equal(res.body.status, "ready");
assert.equal(res.body.db, "connected"); assert.equal(res.body.db, "connected");
}); });
test("GET /races rejects invalid year", async () => { test("production config rejects Turnstile bypass token", () => {
const res = await request(app).get("/races?year=bad").expect(400); assert.throws(
() =>
resolveTurnstileBypassToken({
rawBypassToken: "unsafe-bypass",
securityProfile: "production",
useMockDb: false,
}),
/TURNSTILE_BYPASS_TOKEN/,
);
assert.equal(
resolveTurnstileBypassToken({
rawBypassToken: "dev-bypass",
securityProfile: "development",
useMockDb: false,
}),
"dev-bypass",
);
assert.equal(
resolveTurnstileBypassToken({
rawBypassToken: "mock-bypass",
securityProfile: "production",
useMockDb: true,
}),
"mock-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.equal(res.body.error, "validation_error");
assert.ok(Array.isArray(res.body.details)); assert.ok(Array.isArray(res.body.details));
}); });
test("GET /races rejects month out of range", async () => { test("GET /api/races rejects month out of range", async () => {
const res = await request(app).get("/races?month=13").expect(400); const { agent } = await authAgent();
const res = await agent.get("/api/races?month=13").expect(400);
assert.equal(res.body.error, "validation_error"); assert.equal(res.body.error, "validation_error");
}); });
test("GET /races accepts year and month", async () => { test("GET /api/races accepts year and month", async () => {
const res = await request(app).get("/races?year=2026&month=5").expect(200); const { agent } = await authAgent();
const res = await agent.get("/api/races?year=2026&month=5").expect(200);
assert.ok(Array.isArray(res.body)); assert.ok(Array.isArray(res.body));
}); });
test("GET /races/:id returns not_found", async () => { test("GET /api/races/:id returns not_found", async () => {
const res = await request(app).get("/races/does-not-exist").expect(404); const { agent } = await authAgent();
const res = await agent.get("/api/races/does-not-exist").expect(404);
assert.equal(res.body.error, "not_found"); assert.equal(res.body.error, "not_found");
assert.ok(Array.isArray(res.body.details)); 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("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("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 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("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);
});

View File

@@ -10,13 +10,14 @@
# docker compose -f docker-compose.stack.yml up -d --build # docker compose -f docker-compose.stack.yml up -d --build
# #
# Миграции и seed (один раз после появления БД): # Миграции и seed (один раз после появления БД):
# docker compose -f docker-compose.stack.yml exec backend node dist/migrate.js # docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/migrate.js
# docker compose -f docker-compose.stack.yml exec backend node dist/seed.js # docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/seed.js
# #
# NPM: проброс на порт 3033. Браузер ходит на /api → nginx во фронте → backend:3000. # NPM: проброс на порт 3033. Браузер ходит на /api → nginx во фронте → runners-calendar-backend:3000.
# Имя сервиса уникально в общей сети (не «backend»), чтобы не пересекаться с другими стеками.
services: services:
backend: runners-calendar-backend:
build: build:
context: . context: .
dockerfile: Dockerfile.backend dockerfile: Dockerfile.backend
@@ -38,11 +39,9 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.frontend dockerfile: Dockerfile.frontend
args:
VITE_API_BASE_URL: /api
container_name: runners-calendar-frontend container_name: runners-calendar-frontend
depends_on: depends_on:
- backend - runners-calendar-backend
ports: ports:
- "3033:80" - "3033:80"
restart: unless-stopped restart: unless-stopped

View File

@@ -8,10 +8,9 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Браузер ходит на тот же origin: /api/* → бэкенд без префикса /api # Браузер ходит на тот же origin: /api/* → бэкенд с тем же префиксом /api
location /api/ { location /api/ {
rewrite ^/api/(.*) /$1 break; proxy_pass http://runners-calendar-backend:3000;
proxy_pass http://backend:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

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

@@ -2,13 +2,10 @@
## 1. Base URL ## 1. Base URL
``` SPA всегда отправляет запросы на относительный префикс `/api` текущего origin.
VITE_API_BASE_URL=http://localhost:3001
```
В коде SPA: `import.meta.env.VITE_API_BASE_URL`. - В dev (`npm run dev`): Vite proxy отправляет `/api/*` на `http://localhost:3001/api/*`.
- В Docker/проде: nginx фронта проксирует `/api/*` на хост `runners-calendar-backend:3000` в той же сети (уникальное имя сервиса Compose, без коллизий с чужими стеками).
В Docker-стеке из репозитория образ фронта собирается с **`VITE_API_BASE_URL=/api`**: запросы идут на тот же origin, nginx проксирует `/api` на backend (см. `docker/nginx.frontend.conf`).
## 2. CORS ## 2. CORS
@@ -22,7 +19,7 @@ CORS_ORIGIN=http://localhost:5173
## 3. Эндпоинты ## 3. Эндпоинты
### `GET /health` ### `GET /api/health`
Liveness-проверка (без обращения к БД). Liveness-проверка (без обращения к БД).
@@ -34,7 +31,7 @@ Liveness-проверка (без обращения к БД).
--- ---
### `GET /ready` ### `GET /api/ready`
Readiness-проверка (проверяет подключение к БД). Readiness-проверка (проверяет подключение к БД).
@@ -52,7 +49,7 @@ Readiness-проверка (проверяет подключение к БД).
--- ---
### `GET /races` ### `GET /api/races`
Список забегов, отсортированных по дате. Список забегов, отсортированных по дате.
@@ -70,7 +67,7 @@ Readiness-проверка (проверяет подключение к БД).
**Пример запроса:** **Пример запроса:**
``` ```
GET /races?year=2026&month=5 GET /api/races?year=2026&month=5
``` ```
**Ответ 200:** **Ответ 200:**
@@ -84,6 +81,7 @@ GET /races?year=2026&month=5
"distanceKm": 42.195, "distanceKm": 42.195,
"status": "planned", "status": "planned",
"officialUrl": null, "officialUrl": null,
"coverImageUrl": null,
"startTime": null, "startTime": null,
"clusterSchedule": null, "clusterSchedule": null,
"bibPickup": null, "bibPickup": null,
@@ -99,7 +97,7 @@ GET /races?year=2026&month=5
--- ---
### `GET /races/:id` ### `GET /api/races/:id`
Одна запись по `id`. Одна запись по `id`.
@@ -113,7 +111,7 @@ GET /races?year=2026&month=5
--- ---
### `POST /races` ### `POST /api/races`
Создание забега. Создание забега.
@@ -127,6 +125,7 @@ GET /races?year=2026&month=5
"distanceKm": 10, "distanceKm": 10,
"status": "planned", "status": "planned",
"officialUrl": "https://example.com", "officialUrl": "https://example.com",
"coverImageUrl": "https://example.com/cover.jpg",
"startTime": "09:30", "startTime": "09:30",
"clusterSchedule": null, "clusterSchedule": null,
"bibPickup": null, "bibPickup": null,
@@ -155,7 +154,7 @@ GET /races?year=2026&month=5
--- ---
### `PATCH /races/:id` ### `PATCH /api/races/:id`
Частичное обновление — передавать **только** изменяемые поля. Частичное обновление — передавать **только** изменяемые поля.
@@ -170,7 +169,7 @@ GET /races?year=2026&month=5
} }
``` ```
**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`. **Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `coverImageUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`.
**Ответ 200:** обновлённый объект `Race`. **Ответ 200:** обновлённый объект `Race`.
@@ -188,7 +187,7 @@ GET /races?year=2026&month=5
--- ---
### `DELETE /races/:id` ### `DELETE /api/races/:id`
Удаление забега. Удаление забега.
@@ -210,7 +209,8 @@ GET /races?year=2026&month=5
| `distanceKm` | number | да | да | Дистанция в км | | `distanceKm` | number | да | да | Дистанция в км |
| `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` | | `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` |
| `officialUrl` | string \| null | нет | да | URL организатора | | `officialUrl` | string \| null | нет | да | URL организатора |
| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` | | `coverImageUrl` | string \| null | нет | да | URL обложки забега. При `POST` может быть найден автоматически по `officialUrl`, если не передан вручную |
| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` или `"09:30:00"` (часы:минуты:секунды) |
| `clusterSchedule` | string \| null | нет | да | Расписание кластеров | | `clusterSchedule` | string \| null | нет | да | Расписание кластеров |
| `bibPickup` | string \| null | нет | да | Выдача номеров | | `bibPickup` | string \| null | нет | да | Выдача номеров |
| `bibNumber` | string \| null | нет | да | Стартовый номер | | `bibNumber` | string \| null | нет | да | Стартовый номер |
@@ -220,7 +220,7 @@ GET /races?year=2026&month=5
| `createdAt` | string | — | — | ISO timestamp (read-only) | | `createdAt` | string | — | — | ISO timestamp (read-only) |
| `updatedAt` | string \| null | — | — | ISO timestamp (read-only) | | `updatedAt` | string \| null | — | — | ISO timestamp (read-only) |
## 5. Фильтрация списка (`GET /races`) ## 5. Фильтрация списка (`GET /api/races`)
- **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`. - **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`.
- **`month`** — целое число 112, фильтрует по `EXTRACT(MONTH FROM race_date)`. - **`month`** — целое число 112, фильтрует по `EXTRACT(MONTH FROM race_date)`.
@@ -234,7 +234,7 @@ Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert**
## 7. Поведение при недоступной БД ## 7. Поведение при недоступной БД
- `GET /health` — всегда `200` (не проверяет БД). - `GET /api/health` — всегда `200` (не проверяет БД).
- `GET /ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`. - `GET /api/ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`.
- Все остальные маршруты — `503 { "error": "database_unavailable" }`. - Все остальные маршруты — `503 { "error": "database_unavailable" }`.
- В логах сервера: строка ошибки с контекстом маршрута. - В логах сервера: строка ошибки с контекстом маршрута.

View File

@@ -78,7 +78,7 @@ API слушает порт: **`PORT`** (если задан), иначе **`API
| `API_PORT` | Порт API-сервера | `3001` | | `API_PORT` | Порт API-сервера | `3001` |
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` | | `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
Для локального Vite в корневом `.env.example` также указан **`VITE_API_BASE_URL`** (читает только фронт из `frontend/`). В Docker-стеке базовый URL API задаётся при **сборке** образа фронта (`/api`), не через этот файл. Фронтенд всегда обращается к API по префиксу `/api` на текущем origin. В локальной разработке этот префикс проксирует Vite (`frontend/vite.config.ts`) на `http://localhost:3001`; в Docker-стеке — nginx фронта проксирует на `runners-calendar-backend:3000`.
**Без mock:** при отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`. **Без mock:** при отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`.
@@ -87,8 +87,8 @@ API слушает порт: **`PORT`** (если задан), иначе **`API
## Поведение при недоступной БД ## Поведение при недоступной БД
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт). - **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
- **`GET /health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД). - **`GET /api/health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
- **`GET /ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API). - **`GET /api/ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API).
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`. - **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
## Структура каталога ## Структура каталога
@@ -108,8 +108,8 @@ backend/
│ ├── mappers/ │ ├── mappers/
│ │ └── race.ts # snake_case ↔ camelCase │ │ └── race.ts # snake_case ↔ camelCase
│ └── routes/ │ └── routes/
│ ├── health.ts # /health, /ready │ ├── health.ts # /api/health, /api/ready, /api/meta
│ └── races.ts # CRUD /races │ └── races.ts # CRUD /api/races
├── package.json ├── package.json
└── tsconfig.json └── tsconfig.json
``` ```
@@ -118,4 +118,4 @@ backend/
Файл [`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` должен существовать. Файл [`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` внутри контейнера `backend` (см. комментарии в compose-файле). Порядок после старта контейнеров: `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`) |

View File

@@ -1,2 +1,4 @@
# Для локального npm run dev. Полный список переменных — в корневом .env.example репозитория. # Для production-регистрации укажите публичный site key Cloudflare Turnstile.
VITE_API_BASE_URL=http://localhost:3001 # Без значения локально используется dev bypass token, если он разрешён бэкендом.
# VITE_TURNSTILE_SITE_KEY=
# Полный список переменных окружения — в корневом .env.example репозитория.

View File

@@ -2,6 +2,7 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Календарь стартов</title> <title>Календарь стартов</title>
</head> </head>

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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

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

@@ -0,0 +1,69 @@
import { requestJson, setCsrfToken } from "./http";
import type { 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 }),
});
}

View File

@@ -3,6 +3,12 @@ export type ApiErrorCode =
| "not_found" | "not_found"
| "database_unavailable" | "database_unavailable"
| "conflict" | "conflict"
| "unauthorized"
| "email_not_verified"
| "csrf_error"
| "captcha_failed"
| "invalid_credentials"
| "invalid_token"
| "network_error" | "network_error"
| "unknown_error"; | "unknown_error";
@@ -36,6 +42,12 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
value === "not_found" || value === "not_found" ||
value === "database_unavailable" || value === "database_unavailable" ||
value === "conflict" || value === "conflict" ||
value === "unauthorized" ||
value === "email_not_verified" ||
value === "csrf_error" ||
value === "captcha_failed" ||
value === "invalid_credentials" ||
value === "invalid_token" ||
value === "unknown_error" value === "unknown_error"
) { ) {
return value; return value;
@@ -47,7 +59,7 @@ function isGatewayStatus(status: number): boolean {
return status === 502 || status === 503 || status === 504; return status === 502 || status === 503 || status === 504;
} }
function hasStructuredApiError(payload: unknown): payload is ApiErrorPayload { export function isStructuredApiErrorPayload(payload: unknown): payload is ApiErrorPayload {
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
return false; return false;
} }
@@ -55,7 +67,7 @@ function hasStructuredApiError(payload: unknown): payload is ApiErrorPayload {
} }
export function toApiError(status: number, payload: unknown): ApiError { export function toApiError(status: number, payload: unknown): ApiError {
if (isGatewayStatus(status) && !hasStructuredApiError(payload)) { if (isGatewayStatus(status) && !isStructuredApiErrorPayload(payload)) {
return new ApiError({ return new ApiError({
code: "network_error", code: "network_error",
status, status,
@@ -63,7 +75,7 @@ export function toApiError(status: number, payload: unknown): ApiError {
}); });
} }
if (!hasStructuredApiError(payload) && (status === 401 || status === 403 || status === 404)) { if (!isStructuredApiErrorPayload(payload) && (status === 401 || status === 403 || status === 404)) {
return new ApiError({ return new ApiError({
code: "network_error", code: "network_error",
status, status,
@@ -98,6 +110,18 @@ export function getApiErrorMessage(code: ApiErrorCode): string {
return "Сервис временно недоступен. Попробуйте позже."; return "Сервис временно недоступен. Попробуйте позже.";
case "conflict": case "conflict":
return "Запись с таким идентификатором уже существует."; 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": case "network_error":
return "Не удалось связаться с сервером."; return "Не удалось связаться с сервером.";
case "unknown_error": case "unknown_error":

View File

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

View File

@@ -1,27 +1,54 @@
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 { function buildUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`; const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${API_BASE_URL}${normalizedPath}`; return `${API_ROOT}${normalizedPath}`;
} }
async function parseResponseBody(response: Response): Promise<unknown> { async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? ""; const text = await response.text();
if (!contentType.includes("application/json")) { if (!text.trim()) {
return null; return null;
} }
try { const contentType = response.headers.get("content-type") ?? "";
return await response.json(); if (contentType.includes("application/json")) {
} catch { try {
return null; 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]); 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> { function delay(ms: number): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(resolve, ms); setTimeout(resolve, ms);
@@ -35,10 +62,19 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try { 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), { const response = await fetch(buildUrl(path), {
...init, ...init,
credentials: "include",
headers: { headers: {
"Content-Type": "application/json", ...defaultHeaders,
...(init?.headers ?? {}), ...(init?.headers ?? {}),
}, },
}); });
@@ -50,30 +86,11 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
const payload = await parseResponseBody(response); const payload = await parseResponseBody(response);
if (!response.ok) { if (!response.ok) {
const retryable = idempotent && GATEWAY_RETRY_STATUSES.has(response.status) && attempt < maxAttempts; const retryable = idempotent && attempt < maxAttempts && shouldRetryIdempotentError(response.status, payload);
if (retryable) { if (retryable) {
await delay(80 * attempt); await delay(80 * attempt);
continue; continue;
} }
// #region agent log
fetch("http://127.0.0.1:7488/ingest/a18f912f-72c6-4a58-866b-17810a6b89d2", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "587ee5" },
body: JSON.stringify({
sessionId: "587ee5",
hypothesisId: "H-http-not-ok",
location: "http.ts:requestJson",
message: "HTTP error response",
data: {
path,
status: response.status,
contentType: response.headers.get("content-type"),
payloadIsObject: payload !== null && typeof payload === "object",
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
throw toApiError(response.status, payload); throw toApiError(response.status, payload);
} }

View File

@@ -1,5 +1,15 @@
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; export type { AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export { ApiError, getApiErrorMessage } from "./errors"; export { ApiError, getApiErrorMessage } from "./errors";
export type { HealthResponse } from "./health"; export type { BackendMetaResponse, HealthResponse } from "./health";
export { getHealth } from "./health"; export { getBackendMeta, getHealth } from "./health";
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races"; export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
export {
forgotPassword,
getCurrentUser,
login,
logout,
register,
resendVerification,
resetPassword,
verifyEmail,
} from "./auth";

View File

@@ -10,11 +10,16 @@ function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string"; 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 { function normalizeRace(input: unknown): Race {
const race = input as Partial<Race>; const race = input as Partial<Race>;
const isValid = const isValid =
isString(race?.id) && isString(race?.id) &&
isString(race?.slug) &&
isString(race?.date) && isString(race?.date) &&
isString(race?.title) && isString(race?.title) &&
typeof race?.distanceKm === "number" && typeof race?.distanceKm === "number" &&
@@ -23,6 +28,7 @@ function normalizeRace(input: unknown): Race {
race?.status === "registered" || race?.status === "registered" ||
race?.status === "completed") && race?.status === "completed") &&
isNullableString(race?.officialUrl) && isNullableString(race?.officialUrl) &&
isOptionalNullableString(race?.coverImageUrl) &&
isNullableString(race?.startTime) && isNullableString(race?.startTime) &&
isNullableString(race?.clusterSchedule) && isNullableString(race?.clusterSchedule) &&
isNullableString(race?.bibPickup) && isNullableString(race?.bibPickup) &&
@@ -43,11 +49,13 @@ function normalizeRace(input: unknown): Race {
return { return {
id: race.id, id: race.id,
slug: race.slug,
date: race.date, date: race.date,
title: race.title, title: race.title,
distanceKm: race.distanceKm, distanceKm: race.distanceKm,
status: race.status, status: race.status,
officialUrl: race.officialUrl, officialUrl: race.officialUrl,
coverImageUrl: race.coverImageUrl ?? null,
startTime: race.startTime, startTime: race.startTime,
clusterSchedule: race.clusterSchedule, clusterSchedule: race.clusterSchedule,
bibPickup: race.bibPickup, bibPickup: race.bibPickup,

View File

@@ -2,11 +2,13 @@ export type RaceStatus = "planned" | "registered" | "completed";
export interface Race { export interface Race {
id: string; id: string;
slug: string;
date: string; date: string;
title: string; title: string;
distanceKm: number; distanceKm: number;
status: RaceStatus | null; status: RaceStatus | null;
officialUrl: string | null; officialUrl: string | null;
coverImageUrl: string | null;
startTime: string | null; startTime: string | null;
clusterSchedule: string | null; clusterSchedule: string | null;
bibPickup: string | null; bibPickup: string | null;
@@ -24,12 +26,13 @@ export interface RacesQuery {
} }
export interface CreateRacePayload { export interface CreateRacePayload {
id: string; slug: string;
date: string; date: string;
title: string; title: string;
distanceKm: number; distanceKm: number;
status?: RaceStatus | null; status?: RaceStatus | null;
officialUrl?: string | null; officialUrl?: string | null;
coverImageUrl?: string | null;
startTime?: string | null; startTime?: string | null;
clusterSchedule?: string | null; clusterSchedule?: string | null;
bibPickup?: string | null; bibPickup?: string | null;
@@ -40,3 +43,9 @@ export interface CreateRacePayload {
} }
export type UpdateRacePayload = Partial<Omit<CreateRacePayload, "id">>; export type UpdateRacePayload = Partial<Omit<CreateRacePayload, "id">>;
export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
}

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,16 @@
import { NavLink, Outlet } from "react-router-dom"; import { Link, NavLink, Outlet } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { AppShellFooter } from "./AppShellFooter"; import { AppShellFooter } from "./AppShellFooter";
export function AppLayout(): JSX.Element { export function AppLayout(): JSX.Element {
const { user, logout } = useAuth();
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="app-shell__header"> <header className="app-shell__header">
<div className="app-shell__brand">Календарь стартов</div> <Link className="app-shell__brand" to="/">
Календарь стартов
</Link>
<nav className="app-shell__nav" aria-label="Основная навигация"> <nav className="app-shell__nav" aria-label="Основная навигация">
<NavLink <NavLink
to="/" to="/"
@@ -32,6 +37,20 @@ export function AppLayout(): JSX.Element {
> >
+ Добавить + Добавить
</NavLink> </NavLink>
{user ? (
<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> </nav>
</header> </header>
<main className="app-shell__main"> <main className="app-shell__main">

View File

@@ -1,25 +1,44 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getHealth } from "../../api"; import { getBackendMeta } from "../../api";
import { FRONTEND_VERSION } from "../../frontendVersion"; 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 { export function AppShellFooter(): JSX.Element {
const [backendVersion, setBackendVersion] = useState<string | null>(null); const [backendVersion, setBackendVersion] = useState<string | null>(() => readCachedBackendVersion());
useEffect(() => { useEffect(() => {
const ac = new AbortController(); const ac = new AbortController();
void getHealth({ signal: ac.signal }) void getBackendMeta({ signal: ac.signal })
.then((h) => { .then((meta) => {
if (ac.signal.aborted) { if (ac.signal.aborted) {
return; return;
} }
const v = h.version; const v = meta.version;
setBackendVersion(typeof v === "string" && v.length > 0 ? v : "не указана"); const label = typeof v === "string" && v.length > 0 ? v : "не указана";
writeCachedBackendVersion(label);
setBackendVersion(label);
}) })
.catch(() => { .catch((err) => {
if (ac.signal.aborted) { if (ac.signal.aborted || isAbortError(err)) {
return; return;
} }
setBackendVersion("недоступна"); setBackendVersion((prev) => {
const cached = readCachedBackendVersion();
if (cached) {
return cached;
}
if (prev !== null) {
return prev;
}
return "недоступна";
});
}); });
return () => ac.abort(); return () => ac.abort();
}, []); }, []);

View File

@@ -4,17 +4,31 @@ import { DashboardPage } from "../pages/DashboardPage";
import { RacesPage } from "../pages/RacesPage"; import { RacesPage } from "../pages/RacesPage";
import { RaceDetailsPage } from "../pages/RaceDetailsPage"; import { RaceDetailsPage } from "../pages/RaceDetailsPage";
import { RaceFormPage } from "../pages/RaceFormPage"; import { RaceFormPage } from "../pages/RaceFormPage";
import { RaceDayPage } from "../pages/RaceDayPage";
import { ForgotPasswordPage, LoginPage, RegisterPage, ResetPasswordPage, VerifyEmailPage } from "../pages/AuthPages";
import { RequireAuth } from "./auth/RequireAuth";
export const appRouter = createBrowserRouter([ export const appRouter = createBrowserRouter([
{ {
path: "/", path: "/",
element: <AppLayout />, element: <AppLayout />,
children: [ children: [
{ index: true, element: <DashboardPage /> }, { path: "login", element: <LoginPage /> },
{ path: "races", element: <RacesPage /> }, { path: "register", element: <RegisterPage /> },
{ path: "races/new", element: <RaceFormPage /> }, { path: "verify-email", element: <VerifyEmailPage /> },
{ path: "races/:raceId", element: <RaceDetailsPage /> }, { path: "forgot-password", element: <ForgotPasswordPage /> },
{ path: "races/:raceId/edit", element: <RaceFormPage /> }, { path: "reset-password", element: <ResetPasswordPage /> },
{
element: <RequireAuth />,
children: [
{ index: true, element: <DashboardPage /> },
{ 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

@@ -0,0 +1,197 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { buildMonthCells, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
const MONTH_NAMES_RU = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
interface DatePickerFieldProps {
value: string;
name: string;
required?: boolean;
onChange: (value: string) => void;
}
function parseYmd(value: string): { year: number; monthIndex: number; day: number } | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
}
const year = Number(value.slice(0, 4));
const monthIndex = Number(value.slice(5, 7)) - 1;
const day = Number(value.slice(8, 10));
if (!Number.isInteger(year) || !Number.isInteger(monthIndex) || !Number.isInteger(day)) {
return null;
}
if (monthIndex < 0 || monthIndex > 11) {
return null;
}
return { year, monthIndex, day };
}
function getInitialVisibleMonth(value: string): { year: number; monthIndex: number } {
const parsed = parseYmd(value);
if (parsed) {
return { year: parsed.year, monthIndex: parsed.monthIndex };
}
const now = new Date();
return { year: now.getFullYear(), monthIndex: now.getMonth() };
}
export function DatePickerField(props: DatePickerFieldProps): JSX.Element {
const { value, name, required, onChange } = props;
const [isOpen, setIsOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState(() => getInitialVisibleMonth(value));
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const parsed = parseYmd(value);
if (!parsed) {
return;
}
setVisibleMonth({ year: parsed.year, monthIndex: parsed.monthIndex });
}, [value]);
useEffect(() => {
if (!isOpen) {
return;
}
function handlePointerDown(event: MouseEvent): void {
if (rootRef.current?.contains(event.target as Node)) {
return;
}
setIsOpen(false);
}
function handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const selected = parseYmd(value);
const todayYmd = toYmd(new Date().getFullYear(), new Date().getMonth(), new Date().getDate());
const cells = useMemo(
() => buildMonthCells(visibleMonth.year, visibleMonth.monthIndex),
[visibleMonth],
);
const monthTitle = `${MONTH_NAMES_RU[visibleMonth.monthIndex]} ${visibleMonth.year}`;
function shiftMonth(delta: number): void {
setVisibleMonth((prev) => {
const next = new Date(Date.UTC(prev.year, prev.monthIndex + delta, 1));
return { year: next.getUTCFullYear(), monthIndex: next.getUTCMonth() };
});
}
return (
<div className="date-picker" ref={rootRef}>
<div className="date-picker__control">
<input
className="race-form__input date-picker__input"
type="text"
inputMode="numeric"
name={name}
value={value}
onChange={(event) => {
onChange(event.target.value);
}}
onFocus={() => setIsOpen(true)}
placeholder="2026-05-03"
autoComplete="off"
required={required}
/>
<button
className="date-picker__toggle"
type="button"
aria-label="Открыть календарь"
aria-expanded={isOpen}
onClick={() => setIsOpen((prev) => !prev)}
>
</button>
</div>
{isOpen ? (
<div className="date-picker__popover" role="dialog" aria-label="Выбор даты">
<div className="date-picker__header">
<button
className="date-picker__nav"
type="button"
aria-label="Предыдущий месяц"
onClick={() => shiftMonth(-1)}
>
</button>
<p className="date-picker__title">{monthTitle}</p>
<button
className="date-picker__nav"
type="button"
aria-label="Следующий месяц"
onClick={() => shiftMonth(1)}
>
</button>
</div>
<div className="date-picker__weekdays" aria-hidden>
{WEEKDAY_LABELS_SHORT_RU.map((weekday) => (
<span key={weekday} className="date-picker__weekday">
{weekday}
</span>
))}
</div>
<div className="date-picker__cells">
{cells.map((day, idx) => {
if (day === null) {
return <span key={`empty-${idx}`} className="date-picker__cell date-picker__cell--empty" />;
}
const ymd = toYmd(visibleMonth.year, visibleMonth.monthIndex, day);
const isSelected =
selected?.year === visibleMonth.year &&
selected.monthIndex === visibleMonth.monthIndex &&
selected.day === day;
return (
<button
key={ymd}
className={`date-picker__day${isSelected ? " date-picker__day--selected" : ""}${ymd === todayYmd ? " date-picker__day--today" : ""}`}
type="button"
onClick={() => {
onChange(ymd);
setIsOpen(false);
}}
>
{day}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -55,16 +55,38 @@ export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element {
.join(" "); .join(" ");
const last = series[series.length - 1]!; const last = series[series.length - 1]!;
const best = series.reduce((currentBest, item) => (item.minutes < currentBest.minutes ? item : currentBest), series[0]!);
const dotPoints = 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, id: s.race.id };
});
return ( return (
<div className="pace-chart"> <div className="pace-chart">
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика времени на дистанции"> <svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика времени на дистанции">
<line className="pace-chart__grid-line" x1={pad} y1={pad} x2={w - pad} y2={pad} />
<line className="pace-chart__grid-line" x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} />
<polyline className="pace-chart__line" fill="none" points={points} /> <polyline className="pace-chart__line" fill="none" points={points} />
{dotPoints.map((point, index) => (
<circle
key={point.id}
className={index === dotPoints.length - 1 ? "pace-chart__dot pace-chart__dot--last" : "pace-chart__dot"}
cx={point.x}
cy={point.y}
r="1.6"
/>
))}
</svg> </svg>
<p className="pace-chart__caption"> <div className="pace-chart__stats">
Последний пункт: {formatRaceDate(last.race.date)} {last.race.finishTime} ( <p className="pace-chart__caption">
{last.minutes.toFixed(1)} мин) Последний: {formatRaceDate(last.race.date)} · {last.race.finishTime} · {last.minutes.toFixed(1)} мин
</p> </p>
<p className="pace-chart__caption pace-chart__caption--best">
Лучший: {formatRaceDate(best.race.date)} · {best.race.finishTime} · {best.minutes.toFixed(1)} мин
</p>
</div>
</div> </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

@@ -0,0 +1,157 @@
import { useCallback, useMemo } from "react";
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function parseToParts(value: string): { h: number | null; m: number | null; s: number | null } {
const t = value.trim();
if (!t) {
return { h: null, m: null, s: null };
}
const parts = t.split(":").map((p) => p.trim());
if (parts.length === 2) {
const h = Number(parts[0]);
const m = Number(parts[1]);
if (Number.isInteger(h) && Number.isInteger(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59) {
return { h, m, s: 0 };
}
}
if (parts.length >= 3) {
const h = Number(parts[0]);
const m = Number(parts[1]);
const s = Number(parts[2]);
if (
Number.isInteger(h) &&
Number.isInteger(m) &&
Number.isInteger(s) &&
h >= 0 &&
h <= 23 &&
m >= 0 &&
m <= 59 &&
s >= 0 &&
s <= 59
) {
return { h, m, s };
}
}
return { h: null, m: null, s: null };
}
function partsToString(h: number | null, m: number | null, s: number | null): string {
if (h === null || m === null || s === null) {
return "";
}
return `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
}
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MIN_SEC = Array.from({ length: 60 }, (_, i) => i);
interface StartTimeSelectsProps {
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}
export function StartTimeSelects(props: StartTimeSelectsProps): JSX.Element {
const { value, onChange, disabled } = props;
const { h, m, s } = useMemo(() => parseToParts(value), [value]);
const emit = useCallback(
(nextH: number | null, nextM: number | null, nextS: number | null) => {
onChange(partsToString(nextH, nextM, nextS));
},
[onChange],
);
const hourVal = h === null ? "" : String(h);
const minVal = m === null ? "" : String(m);
const secVal = s === null ? "" : String(s);
return (
<div className="race-form__time-picker">
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Часы</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Часы"
disabled={disabled}
value={hourVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const nh = Number(v);
emit(nh, m ?? 0, s ?? 0);
}}
>
<option value=""></option>
{HOURS.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
<span className="race-form__time-picker__sep" aria-hidden>
:
</span>
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Минуты</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Минуты"
disabled={disabled}
value={minVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const nm = Number(v);
emit(h ?? 0, nm, s ?? 0);
}}
>
<option value=""></option>
{MIN_SEC.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
<span className="race-form__time-picker__sep" aria-hidden>
:
</span>
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Секунды</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Секунды"
disabled={disabled}
value={secVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const ns = Number(v);
emit(h ?? 0, m ?? 0, ns);
}}
>
<option value=""></option>
{MIN_SEC.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
</div>
);
}

View File

@@ -1 +1,4 @@
export { DatePickerField } from "./DatePickerField";
export { PaceTrendChart } from "./PaceTrendChart"; export { PaceTrendChart } from "./PaceTrendChart";
export { RacesCalendar } from "./RacesCalendar";
export { StartTimeSelects } from "./StartTimeSelects";

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

@@ -6,6 +6,7 @@ export {
getRaceStatusClassName, getRaceStatusClassName,
getRaceStatusLabel, getRaceStatusLabel,
isCloseDistance, isCloseDistance,
isRaceDateInPast,
parseFinishTimeToSeconds, parseFinishTimeToSeconds,
parseRaceDate, parseRaceDate,
raceNeedsResultEntry, raceNeedsResultEntry,
@@ -13,3 +14,7 @@ export {
sortByDateDesc, sortByDateDesc,
splitRacesByDate, splitRacesByDate,
} from "./raceMetrics"; } from "./raceMetrics";
export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils";
export { getRaceVisual } from "./raceVisuals";
export type { RaceVisualVariant } from "./raceVisuals";

View File

@@ -12,6 +12,13 @@ export function parseRaceDate(date: string): Date {
return parsed; 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 { export function parseFinishTimeToSeconds(value: string | null): number | null {
if (!value) { if (!value) {
return null; return null;

View File

@@ -0,0 +1,215 @@
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;
}
interface OfficialRaceVisual {
keywords: string[];
imageSrc: 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: "Ночной старт",
},
};
const OFFICIAL_VISUALS: OfficialRaceVisual[] = [
{
keywords: ["забег апрель"],
imageSrc: "https://aprilrun5km.runc.run/uploads/page_card_photos/AprilRun_photo_1.jpg",
label: "Забег Апрель",
},
{
keywords: ["быстрый пес"],
imageSrc: "https://fastdogxc.runc.run/uploads/page_card_photos/Dog_spring_2026-5.jpg",
label: "Кросс",
},
{
keywords: ["лисья гора"],
imageSrc: "https://foxhillxc.runc.run/uploads/page_card_photos/Fox_Spring_2026-0.jpg",
label: "Кросс",
},
{
keywords: ["казанский марафон"],
imageSrc: "https://static.tildacdn.com/tild3961-6436-4462-b738-356665613039/Frame_2131327895.png",
imageFit: "contain",
label: "Казанский марафон",
},
{
keywords: ["мышкинский полумарафон", "по шести холмам"],
imageSrc: "https://static.tildacdn.com/tild6133-6137-4865-b166-623532313531/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["забег.рф", "забег рф"],
imageSrc: "https://xn--80acghh.xn--p1ai/zabeg.jpg",
label: "ЗаБег.РФ",
},
{
keywords: ["переславский марафон", "александровские версты"],
imageSrc: "https://static.tildacdn.com/tild6432-3338-4533-b262-633339353335/photo_1.jpg",
label: "Золотое кольцо",
},
{
keywords: ["красочный забег"],
imageSrc: "https://colorrun5km.runc.run/uploads/page_card_photos/ColorRun2026-1.jpg",
label: "Красочный забег",
},
{
keywords: ["здорово кострома", "здорово, кострома"],
imageSrc: "https://static.tildacdn.com/tild6139-3539-4661-b232-386230336431/kostroma.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ночной забег москва"],
imageSrc: "https://nightrun10km.runc.run/uploads/page_card_photos/NightRun_2026-9.jpg",
label: "Ночной забег",
},
{
keywords: ["белые ночи"],
imageSrc: "https://wnmarathon.runc.run/uploads/page_card_photos/WN_photo_01.jpg",
label: "Белые ночи",
},
{
keywords: ["сергиевым путем", "сергиевым путём"],
imageSrc: "https://static.tildacdn.com/tild6236-3466-4239-b666-393061326338/serg.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ночной забег нижний новгород"],
imageSrc: "https://rrweb.russiarunning.com/-x740/generalimages/0531a1b8-3876-4620-8961-2fa374e474e5.png",
imageFit: "contain",
label: "Ночной забег",
},
{
keywords: ["suvorov extreme"],
imageSrc: "https://goldenultra.ru/grut/files/photos/100.jpg",
label: "Трейл",
},
{
keywords: ["рыбинский полумарафон", "великий хлебный путь"],
imageSrc: "https://static.tildacdn.com/tild6130-3230-4332-b932-366166366633/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ярославский полумарафон", "золотое кольцо"],
imageSrc: "https://static.tildacdn.com/tild6331-6333-4635-b635-376262373361/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["моя столица"],
imageSrc: "https://static.tildacdn.com/tild3263-3036-4639-b830-653365663832/-min.jpg",
imageFit: "contain",
label: "Моя столица",
},
];
function normalizeTitle(value: string): string {
return value
.toLowerCase()
.replaceAll("ё", "е")
.replace(/[«»|]/g, " ")
.replace(/[^\p{L}\p{N}.&]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function getFallbackRaceVisual(race: Race): RaceVisual {
const title = normalizeTitle(race.title);
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,
};
}
const title = normalizeTitle(race.title);
const official = OFFICIAL_VISUALS.find((visual) =>
visual.keywords.some((keyword) => title.includes(normalizeTitle(keyword))),
);
if (!official) {
return fallback;
}
return {
...fallback,
imageSrc: official.imageSrc,
fallbackSrc: fallback.imageSrc,
imageFit: official.imageFit ?? fallback.imageFit,
label: official.label,
};
}

View File

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

View File

@@ -0,0 +1,253 @@
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 handleToken = useCallback((token: string) => setTurnstileToken(token), []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
try {
await register({ email, password, turnstileToken });
setMessage("Проверьте почту: мы отправили ссылку для подтверждения email.");
} catch (err) {
setError(errorMessage(err, "Не удалось зарегистрироваться."));
}
};
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}>Создать аккаунт</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);
useEffect(() => {
const token = params.get("token");
if (!token) {
return;
}
void verifyEmail(token)
.then(async () => {
await refresh();
setMessage("Email подтверждён. Теперь можно пользоваться календарём.");
})
.catch((err) => setError(errorMessage(err, "Ссылка недействительна.")));
}, [params, refresh]);
const resend = async () => {
setError(null);
await resendVerification(email);
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
};
return (
<section className="page page--auth">
<h1 className="page__title">Подтверждение email</h1>
<p className="page__subtitle">Для доступа к календарю подтвердите 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">Отправить письмо ещё раз</button>
</form>
</section>
);
}
export function ForgotPasswordPage(): JSX.Element {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
return (
<section className="page page--auth">
<h1 className="page__title">Сброс пароля</h1>
<form className="auth-form" onSubmit={async (e) => { e.preventDefault(); await forgotPassword(email); setMessage("Если email зарегистрирован, ссылка отправлена."); }}>
<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}
<button className="btn btn--primary" type="submit">Отправить ссылку</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);
return (
<section className="page page--auth">
<h1 className="page__title">Новый пароль</h1>
<form className="auth-form" onSubmit={async (e) => {
e.preventDefault();
const token = params.get("token") ?? "";
try {
await resetPassword(token, password);
setMessage("Пароль обновлён. Теперь войдите заново.");
} catch (err) {
setError(errorMessage(err, "Не удалось обновить пароль."));
}
}}>
<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">{error}</p> : null}
<button className="btn btn--primary" type="submit">Сохранить пароль</button>
</form>
</section>
);
}

View File

@@ -1,4 +1,6 @@
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { Race } from "../api"; import type { Race } from "../api";
import { ApiError, getRaces } from "../api"; import { ApiError, getRaces } from "../api";
import { PaceTrendChart } from "../components"; import { PaceTrendChart } from "../components";
@@ -6,6 +8,7 @@ import {
formatDistance, formatDistance,
formatRaceDate, formatRaceDate,
getRaceCountdownLabel, getRaceCountdownLabel,
getRaceVisual,
getPaceLabel, getPaceLabel,
isCloseDistance, isCloseDistance,
parseFinishTimeToSeconds, parseFinishTimeToSeconds,
@@ -15,6 +18,10 @@ import {
const PR_DISTANCES = [5, 10, 21.1, 42.2] as const; const PR_DISTANCES = [5, 10, 21.1, 42.2] as const;
type DashboardHeroStyle = CSSProperties & {
"--dashboard-hero-image"?: string;
};
function getErrorMessage(error: unknown): string { function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) { if (error instanceof ApiError) {
return error.message; return error.message;
@@ -22,6 +29,10 @@ function getErrorMessage(error: unknown): string {
return "Не удалось загрузить данные обзора."; return "Не удалось загрузить данные обзора.";
} }
function toCssUrl(value: string): string {
return `url(${JSON.stringify(value)})`;
}
export function DashboardPage(): JSX.Element { export function DashboardPage(): JSX.Element {
const [races, setRaces] = useState<Race[]>([]); const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
@@ -61,26 +72,14 @@ export function DashboardPage(): JSX.Element {
const dashboardMetrics = useMemo(() => { const dashboardMetrics = useMemo(() => {
const { upcoming, past } = splitRacesByDate(races); const { upcoming, past } = splitRacesByDate(races);
const completed = races.filter((race) => race.status === "completed");
const nextRace = upcoming[0] ?? null; const nextRace = upcoming[0] ?? null;
const lastResult = past.find((race) => race.status === "completed") ?? null; const lastResult = past.find((race) => race.status === "completed") ?? null;
let personalRecord: Race | null = null; const lastPersonalRecord =
let personalRecordSeconds = Number.POSITIVE_INFINITY; past.find(
(race) => race.status === "completed" && parseFinishTimeToSeconds(race.finishTime) !== null,
for (const race of completed) { ) ?? null;
const finishSeconds = parseFinishTimeToSeconds(race.finishTime);
if (!finishSeconds) {
continue;
}
const candidate = finishSeconds / race.distanceKm;
if (candidate < personalRecordSeconds) {
personalRecordSeconds = candidate;
personalRecord = race;
}
}
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear); const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
@@ -89,7 +88,7 @@ export function DashboardPage(): JSX.Element {
return { return {
nextRace, nextRace,
lastResult, lastResult,
personalRecord, lastPersonalRecord,
seasonTotal: seasonRaces.length, seasonTotal: seasonRaces.length,
seasonCompletedCount: seasonCompleted.length, seasonCompletedCount: seasonCompleted.length,
}; };
@@ -143,6 +142,15 @@ export function DashboardPage(): JSX.Element {
.sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU")); .sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU"));
}, [races]); }, [races]);
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) { if (isLoading) {
return ( return (
<section className="page page--dashboard" aria-busy="true"> <section className="page page--dashboard" aria-busy="true">
@@ -163,60 +171,129 @@ export function DashboardPage(): JSX.Element {
return ( return (
<section className="page page--dashboard"> <section className="page page--dashboard">
<h1 className="page__title">Обзор</h1> <section
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p> 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="Ключевые метрики"> <div className="dashboard-grid" aria-label="Ключевые метрики">
<article className="dashboard-card"> <article
<h2 className="dashboard-card__title">Ближайший старт</h2> className={`dashboard-card dashboard-card--accent-blue${dashboardMetrics.nextRace ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.nextRace ? ( {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__value">{dashboardMetrics.nextRace.title}</p>
<p className="dashboard-card__meta"> <p className="dashboard-card__meta">
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)} {formatRaceDate(dashboardMetrics.nextRace.date)} ·{" "}
{formatDistance(dashboardMetrics.nextRace.distanceKm)}
</p> </p>
<p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</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>
<article className="dashboard-card"> <article
<h2 className="dashboard-card__title">Последний результат</h2> className={`dashboard-card dashboard-card--accent-coral${dashboardMetrics.lastResult ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastResult ? ( {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__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta"> <p className="dashboard-card__meta">
{dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)} {dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)}
</p> </p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</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> <h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__meta"> <p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
{dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)}
</p>
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p>
</> </>
) : (
<p className="dashboard-card__empty">Недостаточно данных для личного рекорда.</p>
)} )}
</article> </article>
<article className="dashboard-card"> <article
className={`dashboard-card dashboard-card--accent-lime${dashboardMetrics.lastPersonalRecord ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastPersonalRecord ? (
<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>
) : (
<>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__empty">Нет завершённых стартов с финишным временем.</p>
</>
)}
</article>
<article className="dashboard-card dashboard-card--season dashboard-card--accent-violet">
<h2 className="dashboard-card__title">Сезон</h2> <h2 className="dashboard-card__title">Сезон</h2>
<p className="dashboard-card__value">{dashboardMetrics.seasonTotal}</p> <p className="dashboard-card__value">{dashboardMetrics.seasonTotal}</p>
<p className="dashboard-card__meta">стартов в этом году</p> <p className="dashboard-card__meta">стартов в этом году</p>
<p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p> <p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p>
<div className="dashboard-card__progress" aria-label={`Сезон завершен на ${seasonProgress}%`}>
<span style={{ width: `${seasonProgress}%` }} />
</div>
</article> </article>
</div> </div>

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

@@ -7,6 +7,7 @@ import {
getPaceLabel, getPaceLabel,
getRaceStatusClassName, getRaceStatusClassName,
getRaceStatusLabel, getRaceStatusLabel,
getRaceVisual,
raceNeedsResultEntry, raceNeedsResultEntry,
} from "../lib"; } from "../lib";
import type { Race } from "../api"; import type { Race } from "../api";
@@ -139,18 +140,40 @@ export function RaceDetailsPage(): JSX.Element {
} }
const isCompleted = race.status === "completed"; const isCompleted = race.status === "completed";
const visual = getRaceVisual(race);
return ( return (
<section className="page page--race-details"> <section className="page page--race-details">
<div className="race-details-header"> <section className={`race-details-hero race-details-hero--${visual.variant}`} aria-label="Карточка старта">
<div className="race-details-header__main"> <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> <h1 className="page__title">{race.title}</h1>
<p className="page__subtitle"> <p className="page__subtitle">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p> </p>
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</div> </div>
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span> </section>
</div>
{raceNeedsResultEntry(race) ? ( {raceNeedsResultEntry(race) ? (
<p className="race-details-past-hint" role="status"> <p className="race-details-past-hint" role="status">

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, createRace, getRaceById, updateRace } from "../api"; import { ApiError, createRace, getRaceById, updateRace } from "../api";
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api"; import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
import { DatePickerField, StartTimeSelects } from "../components";
import { isRaceDateInPast, parseFinishTimeToSeconds } from "../lib";
function slugify(text: string): string { function slugify(text: string): string {
return text return text
@@ -29,6 +31,7 @@ interface FormData {
distanceKm: string; distanceKm: string;
status: string; status: string;
officialUrl: string; officialUrl: string;
coverImageUrl: string;
startTime: string; startTime: string;
clusterSchedule: string; clusterSchedule: string;
bibPickup: string; bibPickup: string;
@@ -44,6 +47,7 @@ const EMPTY_FORM: FormData = {
distanceKm: "", distanceKm: "",
status: "planned", status: "planned",
officialUrl: "", officialUrl: "",
coverImageUrl: "",
startTime: "", startTime: "",
clusterSchedule: "", clusterSchedule: "",
bibPickup: "", bibPickup: "",
@@ -61,6 +65,7 @@ function raceToFormData(race: Race): FormData {
distanceKm: String(race.distanceKm), distanceKm: String(race.distanceKm),
status: race.status ?? "", status: race.status ?? "",
officialUrl: race.officialUrl ?? "", officialUrl: race.officialUrl ?? "",
coverImageUrl: race.coverImageUrl ?? "",
startTime: race.startTime ?? "", startTime: race.startTime ?? "",
clusterSchedule: race.clusterSchedule ?? "", clusterSchedule: race.clusterSchedule ?? "",
bibPickup: race.bibPickup ?? "", bibPickup: race.bibPickup ?? "",
@@ -91,9 +96,21 @@ function validateForm(form: FormData): string[] {
return errors; 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 { export function RaceFormPage(): JSX.Element {
const { raceId } = useParams<{ raceId: string }>(); const { raceId } = useParams<{ raceId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const isEditMode = Boolean(raceId); const isEditMode = Boolean(raceId);
const [form, setForm] = useState<FormData>(EMPTY_FORM); const [form, setForm] = useState<FormData>(EMPTY_FORM);
@@ -135,10 +152,29 @@ export function RaceFormPage(): JSX.Element {
}; };
}, [raceId]); }, [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( const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => { (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = event.target; const { name, value } = event.target;
setForm((prev) => ({ ...prev, [name]: value })); setForm((prev) => {
const next = { ...prev, [name]: value };
if (name === "finishTime") {
const trimmed = value.trim();
if (trimmed !== "" && parseFinishTimeToSeconds(trimmed) !== null) {
return { ...next, status: "completed" };
}
}
return next;
});
}, },
[], [],
); );
@@ -157,10 +193,16 @@ export function RaceFormPage(): JSX.Element {
setIsSaving(true); setIsSaving(true);
try { try {
const statusValue: RaceStatus | null = const finishTrimmed = form.finishTime.trim();
const hasParsedFinish =
finishTrimmed !== "" && parseFinishTimeToSeconds(finishTrimmed) !== null;
let statusValue: RaceStatus | null =
form.status === "planned" || form.status === "registered" || form.status === "completed" form.status === "planned" || form.status === "registered" || form.status === "completed"
? form.status ? form.status
: null; : null;
if (hasParsedFinish) {
statusValue = "completed";
}
if (isEditMode && raceId) { if (isEditMode && raceId) {
const payload: UpdateRacePayload = { const payload: UpdateRacePayload = {
@@ -169,6 +211,7 @@ export function RaceFormPage(): JSX.Element {
distanceKm: parseFloat(form.distanceKm), distanceKm: parseFloat(form.distanceKm),
status: statusValue, status: statusValue,
officialUrl: emptyToNull(form.officialUrl), officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime), startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule), clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup), bibPickup: emptyToNull(form.bibPickup),
@@ -181,14 +224,15 @@ export function RaceFormPage(): JSX.Element {
await updateRace(raceId, payload); await updateRace(raceId, payload);
navigate(`/races/${raceId}`); navigate(`/races/${raceId}`);
} else { } else {
const id = generateId(form.date.trim(), form.title.trim()); const slug = generateId(form.date.trim(), form.title.trim());
const payload: CreateRacePayload = { const payload: CreateRacePayload = {
id, slug,
date: form.date.trim(), date: form.date.trim(),
title: form.title.trim(), title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm), distanceKm: parseFloat(form.distanceKm),
status: statusValue, status: statusValue,
officialUrl: emptyToNull(form.officialUrl), officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime), startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule), clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup), bibPickup: emptyToNull(form.bibPickup),
@@ -214,6 +258,8 @@ export function RaceFormPage(): JSX.Element {
[form, isEditMode, raceId, navigate], [form, isEditMode, raceId, navigate],
); );
const hideOrgScheduleFields = isEditMode && isRaceDateInPast(form.date);
const showResultFields = isRaceDateTodayOrPast(form.date);
const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт"; const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт";
if (isLoading) { if (isLoading) {
@@ -245,17 +291,17 @@ export function RaceFormPage(): JSX.Element {
<fieldset className="race-form__group"> <fieldset className="race-form__group">
<legend className="race-form__legend">Основная информация</legend> <legend className="race-form__legend">Основная информация</legend>
<label className="race-form__field"> <div className="race-form__field">
<span className="race-form__label">Дата *</span> <span className="race-form__label">Дата *</span>
<input <DatePickerField
className="race-form__input"
type="date"
name="date" name="date"
value={form.date} value={form.date}
onChange={handleChange} onChange={(next) => {
setForm((prev) => ({ ...prev, date: next }));
}}
required required
/> />
</label> </div>
<label className="race-form__field"> <label className="race-form__field">
<span className="race-form__label">Название *</span> <span className="race-form__label">Название *</span>
@@ -303,51 +349,69 @@ export function RaceFormPage(): JSX.Element {
<fieldset className="race-form__group"> <fieldset className="race-form__group">
<legend className="race-form__legend">Организация</legend> <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"> <label className="race-form__field">
<span className="race-form__label">Сайт организатора</span> <span className="race-form__label">URL обложки</span>
<input <input
className="race-form__input" className="race-form__input"
type="url" type="url"
name="officialUrl" name="coverImageUrl"
value={form.officialUrl} value={form.coverImageUrl}
onChange={handleChange} onChange={handleChange}
placeholder="https://…" placeholder="https://…"
/> />
</label> </label>
<label className="race-form__field"> {hideOrgScheduleFields ? null : (
<span className="race-form__label">Время старта</span> <div className="race-form__field">
<input <span className="race-form__label">Время старта</span>
className="race-form__input" <StartTimeSelects
type="text" value={form.startTime}
name="startTime" onChange={(next) => {
value={form.startTime} setForm((prev) => ({ ...prev, startTime: next }));
onChange={handleChange} }}
placeholder="09:30" />
/> </div>
</label> )}
<label className="race-form__field"> {hideOrgScheduleFields ? null : (
<span className="race-form__label">Расписание кластеров</span> <label className="race-form__field">
<input <span className="race-form__label">Расписание кластеров</span>
className="race-form__input" <input
type="text" className="race-form__input"
name="clusterSchedule" type="text"
value={form.clusterSchedule} name="clusterSchedule"
onChange={handleChange} value={form.clusterSchedule}
/> onChange={handleChange}
</label> />
</label>
)}
<label className="race-form__field"> {hideOrgScheduleFields ? null : (
<span className="race-form__label">Выдача номеров</span> <label className="race-form__field">
<input <span className="race-form__label">Выдача номеров</span>
className="race-form__input" <input
type="text" className="race-form__input"
name="bibPickup" type="text"
value={form.bibPickup} name="bibPickup"
onChange={handleChange} value={form.bibPickup}
/> onChange={handleChange}
</label> />
</label>
)}
<label className="race-form__field"> <label className="race-form__field">
<span className="race-form__label">Стартовый номер</span> <span className="race-form__label">Стартовый номер</span>
@@ -362,33 +426,35 @@ export function RaceFormPage(): JSX.Element {
</label> </label>
</fieldset> </fieldset>
<fieldset className="race-form__group"> {showResultFields ? (
<legend className="race-form__legend">Результаты</legend> <fieldset className="race-form__group">
<legend className="race-form__legend">Результаты</legend>
<label className="race-form__field"> <label className="race-form__field">
<span className="race-form__label">Финишное время</span> <span className="race-form__label">Финишное время</span>
<input <input
className="race-form__input" className="race-form__input"
type="text" type="text"
name="finishTime" name="finishTime"
value={form.finishTime} value={form.finishTime}
onChange={handleChange} onChange={handleChange}
placeholder="1:45:30" placeholder="1:45:30"
/> />
</label> </label>
<label className="race-form__field"> <label className="race-form__field">
<span className="race-form__label">Место на финише</span> <span className="race-form__label">Место на финише</span>
<input <input
className="race-form__input" className="race-form__input"
type="text" type="text"
name="finishPlace" name="finishPlace"
value={form.finishPlace} value={form.finishPlace}
onChange={handleChange} onChange={handleChange}
placeholder="12/340" placeholder="12/340"
/> />
</label> </label>
</fieldset> </fieldset>
) : null}
<fieldset className="race-form__group"> <fieldset className="race-form__group">
<legend className="race-form__legend">Дополнительно</legend> <legend className="race-form__legend">Дополнительно</legend>

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 { Link } from "react-router-dom";
import type { Race, RacesQuery } from "../api"; import type { Race, RacesQuery } from "../api";
import { ApiError, getRaces } from "../api"; import { ApiError, getRaces } from "../api";
import { RacesCalendar } from "../components/RacesCalendar";
import { import {
formatDistance, formatDistance,
formatRaceDate, formatRaceDate,
getRaceVisual,
getRaceStatusClassName, getRaceStatusClassName,
getRaceStatusLabel, getRaceStatusLabel,
splitRacesByDate, parseRaceDate,
sortByDateAsc,
sortByDateDesc,
} from "../lib"; } from "../lib";
const MONTH_OPTIONS: { value: string; label: string }[] = [ const MONTH_OPTIONS: { value: string; label: string }[] = [
@@ -26,6 +30,11 @@ const MONTH_OPTIONS: { value: string; label: string }[] = [
{ value: "12", label: "Декабрь" }, { value: "12", label: "Декабрь" },
]; ];
const VIEW_STORAGE_KEY = "races-view-mode";
type ViewMode = "list" | "calendar";
type RaceListTab = "upcoming" | "completed";
function yearSelectOptions(): number[] { function yearSelectOptions(): number[] {
const current = new Date().getFullYear(); const current = new Date().getFullYear();
const start = current - 2; const start = current - 2;
@@ -44,29 +53,77 @@ function getErrorMessage(error: unknown): string {
return "Не удалось загрузить календарь стартов."; return "Не удалось загрузить календарь стартов.";
} }
function RaceList(props: { title: string; races: Race[] }): JSX.Element { function readInitialViewMode(): ViewMode {
const { title, races } = props; 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 ( 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> <h2 className="race-list__title">{title}</h2>
{races.length > 0 ? ( {races.length > 0 ? (
<ul className="race-list__items"> <ul className="race-list__items">
{races.map((race) => ( {races.map((race) => {
<li key={race.id} className="race-card"> const visual = getRaceVisual(race);
<div className="race-card__main"> const parsedDate = parseRaceDate(race.date);
<p className="race-card__title"> const day = parsedDate.toLocaleDateString("ru-RU", { day: "2-digit" });
<Link className="race-card__link" to={`/races/${race.id}`}> const month = parsedDate.toLocaleDateString("ru-RU", { month: "short" });
{race.title}
</Link> return (
</p> <li key={race.id} className={`race-card race-card--action race-card--poster race-card--${visual.variant}`}>
<p className="race-card__meta"> <Link
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} className="race-card__link-surface"
</p> to={`/races/${race.id}`}
</div> aria-label={`Старт: ${race.title}`}
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span> >
</li> <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> </ul>
) : ( ) : (
<p className="race-list__empty">Пока нет данных в этом разделе.</p> <p className="race-list__empty">Пока нет данных в этом разделе.</p>
@@ -81,8 +138,35 @@ export function RacesPage(): JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [yearFilter, setYearFilter] = useState<string>(""); const [yearFilter, setYearFilter] = useState<string>("");
const [monthFilter, setMonthFilter] = 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 => { 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 = {}; const q: RacesQuery = {};
if (yearFilter !== "") { if (yearFilter !== "") {
const y = parseInt(yearFilter, 10); const y = parseInt(yearFilter, 10);
@@ -97,7 +181,15 @@ export function RacesPage(): JSX.Element {
} }
} }
return Object.keys(q).length > 0 ? q : undefined; 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(() => { useEffect(() => {
const ac = new AbortController(); const ac = new AbortController();
@@ -131,7 +223,32 @@ export function RacesPage(): JSX.Element {
}; };
}, [listQuery]); }, [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) { if (errorMessage && races.length === 0 && !isLoading) {
return ( return (
@@ -144,61 +261,117 @@ export function RacesPage(): JSX.Element {
return ( return (
<section className="page page--races"> <section className="page page--races">
<h1 className="page__title">Календарь стартов</h1> <section className="races-hero" aria-label="Календарь стартов">
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p> <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>
{errorMessage && !isLoading ? ( <div className="races-status" aria-live="polite">
<p className="page__subtitle page__subtitle--error" role="alert" style={{ marginTop: "var(--space-4)" }}> <p
{errorMessage} className={statusClassName}
role={errorMessage && !isLoading ? "alert" : undefined}
aria-busy={isLoading || undefined}
aria-hidden={!statusMessage || undefined}
>
{statusMessage || "\u00a0"}
</p> </p>
) : null}
<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> </div>
{isLoading ? ( {viewMode === "list" ? (
<p className="page__subtitle" aria-busy="true"> <>
Загружаем данные... <div className="race-list-tabs" role="tablist" aria-label="Раздел стартов">
</p> <button
) : null} type="button"
role="tab"
<div className="race-lists"> aria-selected={activeListTab === "upcoming"}
<RaceList title="Будущие" races={upcoming} /> className={`race-list-tabs__btn${activeListTab === "upcoming" ? " race-list-tabs__btn--active" : ""}`}
<RaceList title="Прошедшие" races={past} /> onClick={() => setActiveListTab("upcoming")}
</div> >
Будущие
<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> </section>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,26 @@
:root { :root {
--color-bg: #f3f5f7; --color-bg: #edf3f6;
--color-bg-deep: #071927;
--color-surface: #ffffff; --color-surface: #ffffff;
--color-text: #13202b; --color-surface-soft: #f7fafc;
--color-text-muted: #5d6b77; --color-text: #0e1f2d;
--color-accent: #1f7ae0; --color-text-muted: #647483;
--color-border: #dce2e8; --color-accent: #1168d8;
--color-success: #2f9e63; --color-accent-strong: #0c48a0;
--color-warning: #c0821f; --color-lime: #b9f24a;
--color-error: #cc3a3a; --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-family-base: "Inter", "Segoe UI", Arial, sans-serif;
--font-size-h1: 2rem; --font-size-h1: 2.35rem;
--font-size-h2: 1.5rem; --font-size-h2: 1.35rem;
--font-size-body: 1rem; --font-size-body: 1rem;
--font-size-caption: 0.875rem; --font-size-caption: 0.875rem;
--line-height-base: 1.5; --line-height-base: 1.45;
--space-1: 0.25rem; --space-1: 0.25rem;
--space-2: 0.5rem; --space-2: 0.5rem;
@@ -25,6 +31,10 @@
--space-8: 2rem; --space-8: 2rem;
--radius-sm: 0.375rem; --radius-sm: 0.375rem;
--radius-md: 0.75rem; --radius-md: 0.625rem;
--radius-lg: 1rem; --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);
} }

View File

@@ -1 +1,12 @@
/// <reference types="vite/client" /> /// <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

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