fix: прод — CORS, версия API, ошибки клиента и подсказка по прошедшим стартам
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

- CORS_ORIGIN: несколько origin через запятую; комментарии в .env.example
- Версия бэкенда: APP_VERSION, безопасное чтение package.json, футер при пустой версии
- Сообщения API: unknown_error и ответы 401/403/404 без JSON; отладочный лог при !ok
- Статус «внесите результат» для прошедшей даты + блок на карточке старта и стили
This commit is contained in:
Vaka.pro
2026-04-08 01:21:11 +03:00
parent 8442c761c2
commit e0ed0b6435
11 changed files with 140 additions and 19 deletions

View File

@@ -24,10 +24,16 @@ API_PORT=3001
# CALENDAR_RUN_MOCK_DB=1 # CALENDAR_RUN_MOCK_DB=1
# ─── CORS ──────────────────────────────────────────────────── # ─── CORS ────────────────────────────────────────────────────
# Должен совпадать с origin в браузере (схема + хост + порт, без пути), иначе API «молчит».
# Локальный Vite: http://localhost:5173 # Локальный Vite: http://localhost:5173
# Стек с фронтом на 3033: http://localhost:3033 # Стек с фронтом на 3033: http://localhost:3033
# Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru
CORS_ORIGIN=http://localhost:5173 CORS_ORIGIN=http://localhost:5173
# ─── Версия API (опционально) ─────────────────────────────────
# Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health).
# APP_VERSION=1.0.0
# ─── Frontend (Vite, локально из каталога frontend/) ───────── # ─── Frontend (Vite, локально из каталога frontend/) ─────────
# В Docker-образе фронта базовый URL API задаётся при сборке (/api), не из .env. # В Docker-образе фронта базовый URL API задаётся при сборке (/api), не из .env.
VITE_API_BASE_URL=http://localhost:3001 VITE_API_BASE_URL=http://localhost:3001

View File

@@ -33,5 +33,21 @@ export const config = {
password: requireEnv("DB_PASSWORD"), password: requireEnv("DB_PASSWORD"),
}, },
apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10), apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10),
corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173", /** Одно значение или несколько через запятую (прод: https://домен) */
corsOrigin: parseCorsOrigins(),
}; };
function parseCorsOrigins(): string | string[] {
const raw = process.env.CORS_ORIGIN?.trim();
if (!raw) {
return "http://localhost:5173";
}
const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) {
return "http://localhost:5173";
}
if (parts.length === 1) {
return parts[0]!;
}
return parts;
}

View File

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

View File

@@ -35,7 +35,8 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
value === "validation_error" || value === "validation_error" ||
value === "not_found" || value === "not_found" ||
value === "database_unavailable" || value === "database_unavailable" ||
value === "conflict" value === "conflict" ||
value === "unknown_error"
) { ) {
return value; return value;
} }
@@ -62,6 +63,17 @@ export function toApiError(status: number, payload: unknown): ApiError {
}); });
} }
if (!hasStructuredApiError(payload) && (status === 401 || status === 403 || status === 404)) {
return new ApiError({
code: "network_error",
status,
message:
status === 404
? "API не найден по этому адресу. Проверьте прокси и префикс /api."
: "Запрос отклонён сервером. Проверьте переменную CORS_ORIGIN на бэкенде.",
});
}
const maybePayload = payload as ApiErrorPayload; const maybePayload = payload as ApiErrorPayload;
const code = normalizeApiCode(maybePayload?.error); const code = normalizeApiCode(maybePayload?.error);
const details = Array.isArray(maybePayload?.details) const details = Array.isArray(maybePayload?.details)
@@ -88,6 +100,8 @@ export function getApiErrorMessage(code: ApiErrorCode): string {
return "Запись с таким идентификатором уже существует."; return "Запись с таким идентификатором уже существует.";
case "network_error": case "network_error":
return "Не удалось связаться с сервером."; return "Не удалось связаться с сервером.";
case "unknown_error":
return "Сервер не смог обработать запрос. Попробуйте позже или обновите страницу.";
default: default:
return "Произошла неизвестная ошибка."; return "Произошла неизвестная ошибка.";
} }

View File

@@ -55,6 +55,25 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
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

@@ -12,7 +12,8 @@ export function AppShellFooter(): JSX.Element {
if (ac.signal.aborted) { if (ac.signal.aborted) {
return; return;
} }
setBackendVersion(h.version); const v = h.version;
setBackendVersion(typeof v === "string" && v.length > 0 ? v : "не указана");
}) })
.catch(() => { .catch(() => {
if (ac.signal.aborted) { if (ac.signal.aborted) {

View File

@@ -8,6 +8,7 @@ export {
isCloseDistance, isCloseDistance,
parseFinishTimeToSeconds, parseFinishTimeToSeconds,
parseRaceDate, parseRaceDate,
raceNeedsResultEntry,
sortByDateAsc, sortByDateAsc,
sortByDateDesc, sortByDateDesc,
splitRacesByDate, splitRacesByDate,

View File

@@ -122,18 +122,36 @@ export function getPaceLabel(finishTime: string | null, distanceKm: number): str
return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`; return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`;
} }
export function getRaceStatusClassName(status: Race["status"]): string { function isPastDateNeedingResult(status: Race["status"], raceDate: string): boolean {
const base = "race-card__status"; if (status !== "planned" && status !== "registered") {
if (status === "completed") { return false;
return `${base} ${base}--completed`;
} }
if (status === "registered") { const today = new Date();
return `${base} ${base}--registered`; today.setHours(0, 0, 0, 0);
} return parseRaceDate(raceDate).getTime() < today.getTime();
return `${base} ${base}--planned`;
} }
export function getRaceStatusLabel(status: Race["status"]): string { export function raceNeedsResultEntry(race: Race): boolean {
return isPastDateNeedingResult(race.status, race.date);
}
export function getRaceStatusClassName(status: Race["status"], raceDate?: string): string {
const base = "race-card__status";
let tier = `${base}--planned`;
if (status === "completed") {
tier = `${base}--completed`;
} else if (status === "registered") {
tier = `${base}--registered`;
}
const needs =
raceDate && isPastDateNeedingResult(status, raceDate) ? ` ${base}--needs-result` : "";
return `${base} ${tier}${needs}`;
}
export function getRaceStatusLabel(status: Race["status"], raceDate?: string): string {
if (raceDate && isPastDateNeedingResult(status, raceDate)) {
return "внесите результат";
}
if (status === "completed") { if (status === "completed") {
return "пробежал"; return "пробежал";
} }

View File

@@ -7,6 +7,7 @@ import {
getPaceLabel, getPaceLabel,
getRaceStatusClassName, getRaceStatusClassName,
getRaceStatusLabel, getRaceStatusLabel,
raceNeedsResultEntry,
} from "../lib"; } from "../lib";
import type { Race } from "../api"; import type { Race } from "../api";
@@ -148,9 +149,19 @@ export function RaceDetailsPage(): JSX.Element {
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p> </p>
</div> </div>
<span className={getRaceStatusClassName(race.status)}>{getRaceStatusLabel(race.status)}</span> <span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
</div> </div>
{raceNeedsResultEntry(race) ? (
<p className="race-details-past-hint" role="status">
Дата старта уже прошла {" "}
<Link className="race-details-past-hint__link" to={`/races/${race.id}/edit`}>
внесите результат или обновите статус
</Link>
.
</p>
) : null}
<div className="race-details-actions"> <div className="race-details-actions">
<Link className="btn btn--primary" to={`/races/${race.id}/edit`}> <Link className="btn btn--primary" to={`/races/${race.id}/edit`}>
Редактировать Редактировать
@@ -202,7 +213,7 @@ export function RaceDetailsPage(): JSX.Element {
</div> </div>
<div className="race-details-meta__item"> <div className="race-details-meta__item">
<dt className="race-details-meta__key">Статус</dt> <dt className="race-details-meta__key">Статус</dt>
<dd className="race-details-meta__value">{getRaceStatusLabel(race.status)}</dd> <dd className="race-details-meta__value">{getRaceStatusLabel(race.status, race.date)}</dd>
</div> </div>
<DetailLink label="Сайт организатора" url={race.officialUrl} /> <DetailLink label="Сайт организатора" url={race.officialUrl} />
<DetailItem label="Время старта" value={race.startTime} /> <DetailItem label="Время старта" value={race.startTime} />

View File

@@ -64,7 +64,7 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p> </p>
</div> </div>
<span className={getRaceStatusClassName(race.status)}>{getRaceStatusLabel(race.status)}</span> <span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -291,6 +291,31 @@ a {
color: #8a5a00; color: #8a5a00;
} }
.race-card__status--needs-result {
outline: 1px solid var(--color-warning);
}
.race-details-past-hint {
margin: 0 0 var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--color-warning);
background: #fffaf0;
color: var(--color-text);
font-size: var(--font-size-caption);
}
.race-details-past-hint__link {
color: var(--color-accent);
font-weight: 600;
text-decoration: underline;
}
.race-details-past-hint__link:hover,
.race-details-past-hint__link:focus-visible {
outline: none;
}
.races-filter { .races-filter {
margin-top: var(--space-5); margin-top: var(--space-5);
display: flex; display: flex;