feat(backend): implement REST API for races calendar

Express + TypeScript backend with PostgreSQL: CRUD endpoints for /races (GET list with year/month filters, GET by id, POST, PATCH, DELETE), health/readiness probes, SQL migration runner, seed script with upsert from CSV, camelCase/snake_case mapper, CORS, env validation, docker-compose, and API docs for frontend.

Made-with: Cursor
This commit is contained in:
Anton
2026-04-01 14:47:53 +03:00
parent 88a448dd8e
commit 698ae37553
17 changed files with 2242 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
/** Row shape returned by PostgreSQL (snake_case). */
export interface RaceRow {
id: string;
race_date: string;
title: string;
distance_km: string;
status: string | null;
official_url: string | null;
start_time: string | null;
cluster_schedule: string | null;
bib_pickup: string | null;
bib_number: string | null;
finish_time: string | null;
notes: string | null;
created_at: string;
updated_at: string | null;
}
/** API shape (camelCase). */
export interface RaceDto {
id: string;
date: string;
title: string;
distanceKm: number;
status: string | 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;
}
/** Convert a DB row to the API DTO (camelCase). */
export function rowToDto(row: RaceRow): RaceDto {
return {
id: row.id,
date: row.race_date,
title: row.title,
distanceKm: parseFloat(row.distance_km),
status: row.status,
officialUrl: row.official_url,
startTime: row.start_time,
clusterSchedule: row.cluster_schedule,
bibPickup: row.bib_pickup,
bibNumber: row.bib_number,
finishTime: row.finish_time,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/** Map incoming camelCase body fields to snake_case column names. */
const FIELD_MAP: Record<string, string> = {
date: "race_date",
title: "title",
distanceKm: "distance_km",
status: "status",
officialUrl: "official_url",
startTime: "start_time",
clusterSchedule: "cluster_schedule",
bibPickup: "bib_pickup",
bibNumber: "bib_number",
finishTime: "finish_time",
notes: "notes",
};
/**
* Convert a partial camelCase body into { columns, values, placeholders }
* suitable for SQL INSERT / UPDATE.
*/
export function bodyToColumns(body: Record<string, unknown>) {
const columns: string[] = [];
const values: unknown[] = [];
for (const [camel, snake] of Object.entries(FIELD_MAP)) {
if (camel in body) {
columns.push(snake);
values.push(body[camel]);
}
}
return { columns, values };
}