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

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

@@ -0,0 +1,24 @@
import path from "path";
import dotenv from "dotenv";
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const config = {
db: {
host: requireEnv("DB_HOST"),
port: parseInt(requireEnv("DB_PORT"), 10),
database: requireEnv("DB_NAME"),
user: requireEnv("DB_USER"),
password: requireEnv("DB_PASSWORD"),
},
apiPort: parseInt(process.env.API_PORT || "3001", 10),
corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173",
};

29
backend/src/db.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Pool, PoolConfig } from "pg";
import { config } from "./config";
const poolConfig: PoolConfig = {
host: config.db.host,
port: config.db.port,
database: config.db.database,
user: config.db.user,
password: config.db.password,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
};
export const pool = new Pool(poolConfig);
pool.on("error", (err) => {
console.error("[db] Unexpected pool error:", err.message);
});
export async function checkDbConnection(): Promise<boolean> {
try {
const client = await pool.connect();
client.release();
return true;
} catch {
return false;
}
}

18
backend/src/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import express from "express";
import cors from "cors";
import { config } from "./config";
import healthRouter from "./routes/health";
import racesRouter from "./routes/races";
const app = express();
app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] }));
app.use(express.json());
app.use(healthRouter);
app.use(racesRouter);
app.listen(config.apiPort, () => {
console.log(`[api] Listening on http://localhost:${config.apiPort}`);
console.log(`[api] CORS origin: ${config.corsOrigin}`);
});

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 };
}

46
backend/src/migrate.ts Normal file
View File

@@ -0,0 +1,46 @@
import fs from "fs";
import path from "path";
import { pool } from "./db";
async function migrate() {
console.log("[migrate] Running migrations…");
const migrationsDir = path.resolve(__dirname, "../migrations");
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS _migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
)
`);
for (const file of files) {
const { rowCount } = await client.query(
"SELECT 1 FROM _migrations WHERE filename = $1",
[file],
);
if (rowCount && rowCount > 0) {
console.log(`[migrate] Already applied: ${file}`);
continue;
}
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8");
await client.query(sql);
await client.query("INSERT INTO _migrations (filename) VALUES ($1)", [file]);
console.log(`[migrate] Applied: ${file}`);
}
console.log("[migrate] Done.");
} finally {
client.release();
await pool.end();
}
}
migrate().catch((err) => {
console.error("[migrate] FAILED:", err.message);
process.exit(1);
});

View File

@@ -0,0 +1,19 @@
import { Router, Request, Response } from "express";
import { checkDbConnection } from "../db";
const router = Router();
router.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok" });
});
router.get("/ready", async (_req: Request, res: Response) => {
const dbOk = await checkDbConnection();
if (dbOk) {
res.json({ status: "ready", db: "connected" });
} else {
res.status(503).json({ error: "database_unavailable", db: "disconnected" });
}
});
export default router;

142
backend/src/routes/races.ts Normal file
View File

@@ -0,0 +1,142 @@
import { Router, Request, Response } from "express";
import { pool } from "../db";
import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
const router = Router();
function dbError(res: Response) {
res.status(503).json({ error: "database_unavailable" });
}
/* ─── GET /races ──────────────────────────────────────────── */
router.get("/races", async (req: Request, res: Response) => {
try {
const { year, month } = req.query;
const conditions: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (year) {
conditions.push(`EXTRACT(YEAR FROM race_date) = $${idx++}`);
params.push(Number(year));
}
if (month) {
conditions.push(`EXTRACT(MONTH FROM race_date) = $${idx++}`);
params.push(Number(month));
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `SELECT * FROM races ${where} ORDER BY race_date ASC`;
const { rows } = await pool.query<RaceRow>(sql, params);
res.json(rows.map(rowToDto));
} catch (err) {
console.error("[GET /races]", err);
dbError(res);
}
});
/* ─── GET /races/:id ──────────────────────────────────────── */
router.get("/races/:id", async (req: Request, res: Response) => {
try {
const { rows } = await pool.query<RaceRow>(
"SELECT * FROM races WHERE id = $1",
[req.params.id],
);
if (rows.length === 0) {
res.status(404).json({ error: "not_found" });
return;
}
res.json(rowToDto(rows[0]));
} catch (err) {
console.error("[GET /races/:id]", err);
dbError(res);
}
});
/* ─── POST /races ─────────────────────────────────────────── */
router.post("/races", async (req: Request, res: Response) => {
const body = req.body;
if (!body.id || !body.date || !body.title || body.distanceKm == null) {
res.status(400).json({
error: "validation_error",
details: ["Fields id, date, title, distanceKm are required"],
});
return;
}
const { columns, values } = bodyToColumns(body);
columns.unshift("id");
values.unshift(body.id);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
try {
const { rows } = await pool.query<RaceRow>(sql, values);
res.status(201).json(rowToDto(rows[0]));
} catch (err: any) {
if (err.code === "23505") {
res.status(409).json({ error: "conflict", details: ["Race with this id already exists"] });
return;
}
console.error("[POST /races]", err);
dbError(res);
}
});
/* ─── PATCH /races/:id ────────────────────────────────────── */
router.patch("/races/:id", async (req: Request, res: Response) => {
const { columns, values } = bodyToColumns(req.body);
if (columns.length === 0) {
res.status(400).json({
error: "validation_error",
details: ["No updatable fields provided"],
});
return;
}
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`);
values.push(req.params.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length} RETURNING *`;
try {
const { rows } = await pool.query<RaceRow>(sql, values);
if (rows.length === 0) {
res.status(404).json({ error: "not_found" });
return;
}
res.json(rowToDto(rows[0]));
} catch (err) {
console.error("[PATCH /races/:id]", err);
dbError(res);
}
});
/* ─── DELETE /races/:id ───────────────────────────────────── */
router.delete("/races/:id", async (req: Request, res: Response) => {
try {
const { rowCount } = await pool.query(
"DELETE FROM races WHERE id = $1",
[req.params.id],
);
if (rowCount === 0) {
res.status(404).json({ error: "not_found" });
return;
}
res.status(204).end();
} catch (err) {
console.error("[DELETE /races/:id]", err);
dbError(res);
}
});
export default router;

74
backend/src/seed.ts Normal file
View File

@@ -0,0 +1,74 @@
import fs from "fs";
import path from "path";
import { parse } from "csv-parse/sync";
import { pool } from "./db";
interface CsvRow {
date: string;
month: string;
day: string;
event: string;
distance_km: string;
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[«»"]/g, "")
.replace(/[^a-zа-яё0-9]+/gi, "-")
.replace(/(^-|-$)/g, "")
.substring(0, 60);
}
function makeId(date: string, title: string): string {
return `${date}-${slugify(title)}`;
}
async function seed() {
const csvPath = path.resolve(__dirname, "../../import/races_2026_calendar.csv");
if (!fs.existsSync(csvPath)) {
console.error(`[seed] CSV not found: ${csvPath}`);
process.exit(1);
}
const raw = fs.readFileSync(csvPath, "utf-8");
const records: CsvRow[] = parse(raw, {
columns: true,
skip_empty_lines: true,
trim: true,
});
console.log(`[seed] Parsed ${records.length} rows from CSV`);
const client = await pool.connect();
try {
for (const row of records) {
const id = makeId(row.date, row.event);
const distanceKm = parseFloat(row.distance_km);
await client.query(
`INSERT INTO races (id, race_date, title, distance_km, status)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
race_date = EXCLUDED.race_date,
title = EXCLUDED.title,
distance_km = EXCLUDED.distance_km,
status = EXCLUDED.status,
updated_at = NOW()`,
[id, row.date, row.event, distanceKm, "planned"],
);
console.log(`[seed] Upserted: ${id}`);
}
console.log("[seed] Done.");
} finally {
client.release();
await pool.end();
}
}
seed().catch((err) => {
console.error("[seed] FAILED:", err.message);
process.exit(1);
});