diff --git a/PLAN.md b/PLAN.md index e56f43c..4f2735f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -177,7 +177,7 @@ flowchart LR ## 10. Чеклист задач (implementation todos) 1. Монорепо: `frontend/` + `backend/`, BEM, токены, роутер. -2. Postgres в docker-compose, миграции таблицы `races`, бэкенд читает `DB_`*. +2. Postgres в docker-compose, миграции таблицы `races`, бэкенд читает `DB`_*. 3. REST CRUD + разовый seed (CSV и/или JSON) → БД. 4. Клиент API на фронте, типы, загрузка данных для экранов и PR. 5. Экраны месяц и год, модалка по дате. diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts new file mode 100644 index 0000000..e1eeca5 --- /dev/null +++ b/frontend/src/api/errors.ts @@ -0,0 +1,75 @@ +export type ApiErrorCode = + | "validation_error" + | "not_found" + | "database_unavailable" + | "conflict" + | "network_error" + | "unknown_error"; + +export interface ApiErrorPayload { + error?: string; + details?: string[]; +} + +export class ApiError extends Error { + public readonly code: ApiErrorCode; + public readonly status: number | null; + public readonly details: string[]; + + constructor(params: { + code: ApiErrorCode; + message: string; + status?: number | null; + details?: string[]; + }) { + super(params.message); + this.name = "ApiError"; + this.code = params.code; + this.status = params.status ?? null; + this.details = params.details ?? []; + } +} + +function normalizeApiCode(value: string | undefined): ApiErrorCode { + if ( + value === "validation_error" || + value === "not_found" || + value === "database_unavailable" || + value === "conflict" + ) { + return value; + } + return "unknown_error"; +} + +export function toApiError(status: number, payload: unknown): ApiError { + const maybePayload = payload as ApiErrorPayload; + const code = normalizeApiCode(maybePayload?.error); + const details = Array.isArray(maybePayload?.details) + ? maybePayload.details.filter((item): item is string => typeof item === "string") + : []; + + return new ApiError({ + code, + status, + message: getApiErrorMessage(code), + details, + }); +} + +export function getApiErrorMessage(code: ApiErrorCode): string { + switch (code) { + case "validation_error": + return "Проверьте введённые данные и попробуйте снова."; + case "not_found": + return "Запись не найдена."; + case "database_unavailable": + return "Сервис временно недоступен. Попробуйте позже."; + case "conflict": + return "Запись с таким идентификатором уже существует."; + case "network_error": + return "Не удалось связаться с сервером."; + default: + return "Произошла неизвестная ошибка."; + } +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..af9932f --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,55 @@ +import { ApiError, toApiError } from "./errors"; + +const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "http://localhost:3001"; + +function buildUrl(path: string): string { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `${API_BASE_URL}${normalizedPath}`; +} + +async function parseResponseBody(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + return null; + } + + try { + return await response.json(); + } catch { + return null; + } +} + +export async function requestJson(path: string, init?: RequestInit): Promise { + try { + const response = await fetch(buildUrl(path), { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + + if (response.status === 204) { + return undefined as T; + } + + const payload = await parseResponseBody(response); + + if (!response.ok) { + throw toApiError(response.status, payload); + } + + return payload as T; + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + + throw new ApiError({ + code: "network_error", + status: null, + message: "Не удалось связаться с сервером.", + }); + } +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index cb0ff5c..fac7b8f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1 +1,3 @@ -export {}; +export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; +export { ApiError, getApiErrorMessage } from "./errors"; +export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races"; diff --git a/frontend/src/api/races.ts b/frontend/src/api/races.ts new file mode 100644 index 0000000..4a4bbae --- /dev/null +++ b/frontend/src/api/races.ts @@ -0,0 +1,112 @@ +import { ApiError } from "./errors"; +import { requestJson } from "./http"; +import type { CreateRacePayload, Race, RacesQuery, UpdateRacePayload } from "./types"; + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function normalizeRace(input: unknown): Race { + const race = input as Partial; + + const isValid = + isString(race?.id) && + isString(race?.date) && + isString(race?.title) && + typeof race?.distanceKm === "number" && + (race?.status === null || race?.status === "planned" || race?.status === "completed") && + isNullableString(race?.officialUrl) && + isNullableString(race?.startTime) && + isNullableString(race?.clusterSchedule) && + isNullableString(race?.bibPickup) && + isNullableString(race?.bibNumber) && + isNullableString(race?.finishTime) && + isNullableString(race?.notes) && + isString(race?.createdAt) && + (race?.updatedAt === null || isString(race?.updatedAt)); + + if (!isValid) { + throw new ApiError({ + code: "unknown_error", + status: null, + message: "Некорректный формат данных от API.", + }); + } + + return { + id: race.id, + date: race.date, + title: race.title, + distanceKm: race.distanceKm, + status: race.status, + officialUrl: race.officialUrl, + startTime: race.startTime, + clusterSchedule: race.clusterSchedule, + bibPickup: race.bibPickup, + bibNumber: race.bibNumber, + finishTime: race.finishTime, + notes: race.notes, + createdAt: race.createdAt, + updatedAt: race.updatedAt, + }; +} + +function buildRacesQuery(query?: RacesQuery): string { + if (!query) { + return ""; + } + + const params = new URLSearchParams(); + if (typeof query.year === "number") { + params.set("year", String(query.year)); + } + if (typeof query.month === "number") { + params.set("month", String(query.month)); + } + + const serialized = params.toString(); + return serialized ? `?${serialized}` : ""; +} + +export async function getRaces(query?: RacesQuery): Promise { + const response = await requestJson(`/races${buildRacesQuery(query)}`); + if (!Array.isArray(response)) { + throw new ApiError({ + code: "unknown_error", + status: null, + message: "Некорректный формат списка забегов от API.", + }); + } + + return response.map(normalizeRace); +} + +export async function getRaceById(id: string): Promise { + return normalizeRace(await requestJson(`/races/${id}`)); +} + +export async function createRace(payload: CreateRacePayload): Promise { + return normalizeRace( + await requestJson("/races", { + method: "POST", + body: JSON.stringify(payload), + }), + ); +} + +export async function updateRace(id: string, payload: UpdateRacePayload): Promise { + return normalizeRace( + await requestJson(`/races/${id}`, { + method: "PATCH", + body: JSON.stringify(payload), + }), + ); +} + +export async function deleteRace(id: string): Promise { + await requestJson(`/races/${id}`, { method: "DELETE" }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..f2fa8f1 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,40 @@ +export type RaceStatus = "planned" | "completed"; + +export interface Race { + id: string; + date: string; + title: string; + distanceKm: number; + status: RaceStatus | null; + officialUrl: string | null; + startTime: string | null; + clusterSchedule: string | null; + bibPickup: string | null; + bibNumber: string | null; + finishTime: string | null; + notes: string | null; + createdAt: string; + updatedAt: string | null; +} + +export interface RacesQuery { + year?: number; + month?: number; +} + +export interface CreateRacePayload { + id: string; + date: string; + title: string; + distanceKm: number; + status?: RaceStatus | null; + officialUrl?: string | null; + startTime?: string | null; + clusterSchedule?: string | null; + bibPickup?: string | null; + bibNumber?: string | null; + finishTime?: string | null; + notes?: string | null; +} + +export type UpdateRacePayload = Partial>;