import { Router, Request, Response } from "express"; import { pool } from "../db"; import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race"; import { extractRaceCoverImage } from "../raceCoverImage"; const router = Router(); type ValidationErrorBody = { error: "validation_error"; details: string[]; }; function dbError(res: Response) { res.status(503).json({ error: "database_unavailable" }); } function validationError(res: Response, details: string[]) { const body: ValidationErrorBody = { error: "validation_error", details }; res.status(400).json(body); } function parseOptionalIntegerQuery( value: unknown, fieldName: string, min?: number, max?: number, ): { value?: number; error?: string } { if (value == null) { return {}; } if (typeof value !== "string" || value.trim() === "") { return { error: `${fieldName} must be an integer` }; } const normalized = value.trim(); if (!/^-?\d+$/.test(normalized)) { return { error: `${fieldName} must be an integer` }; } const parsed = Number(normalized); if (!Number.isInteger(parsed)) { return { error: `${fieldName} must be an integer` }; } if (min != null && parsed < min) { return { error: `${fieldName} must be between ${min} and ${max}` }; } if (max != null && parsed > max) { return { error: `${fieldName} must be between ${min} and ${max}` }; } return { value: parsed }; } /* ─── GET /races ──────────────────────────────────────────── */ router.get("/races", async (req: Request, res: Response) => { const yearResult = parseOptionalIntegerQuery(req.query.year, "year"); const monthResult = parseOptionalIntegerQuery(req.query.month, "month", 1, 12); const details = [yearResult.error, monthResult.error].filter(Boolean) as string[]; if (details.length > 0) { validationError(res, details); return; } try { const conditions: string[] = []; const params: unknown[] = []; let idx = 1; if (yearResult.value != null) { conditions.push(`EXTRACT(YEAR FROM race_date) = $${idx++}`); params.push(yearResult.value); } if (monthResult.value != null) { conditions.push(`EXTRACT(MONTH FROM race_date) = $${idx++}`); params.push(monthResult.value); } const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; const sql = `SELECT * FROM races ${where} ORDER BY race_date ASC`; const { rows } = await pool.query(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( "SELECT * FROM races WHERE id = $1", [req.params.id], ); if (rows.length === 0) { res.status(404).json({ error: "not_found", details: ["Race 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) { validationError(res, ["Fields id, date, title, distanceKm are required"]); return; } const payload = { ...body }; const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== ""; const hasOfficialUrl = typeof payload.officialUrl === "string" && payload.officialUrl.trim() !== ""; if (!hasManualCover && hasOfficialUrl) { payload.coverImageUrl = await extractRaceCoverImage(payload.officialUrl); } const { columns, values } = bodyToColumns(payload); 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(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) { validationError(res, ["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(sql, values); if (rows.length === 0) { res.status(404).json({ error: "not_found", details: ["Race 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", details: ["Race not found"] }); return; } res.status(204).end(); } catch (err) { console.error("[DELETE /races/:id]", err); dbError(res); } }); export default router;