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:
24
backend/src/config.ts
Normal file
24
backend/src/config.ts
Normal 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
29
backend/src/db.ts
Normal 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
18
backend/src/index.ts
Normal 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}`);
|
||||
});
|
||||
88
backend/src/mappers/race.ts
Normal file
88
backend/src/mappers/race.ts
Normal 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
46
backend/src/migrate.ts
Normal 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);
|
||||
});
|
||||
19
backend/src/routes/health.ts
Normal file
19
backend/src/routes/health.ts
Normal 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
142
backend/src/routes/races.ts
Normal 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
74
backend/src/seed.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user