/** * Row shape returned by PostgreSQL (snake_case). * pg returns DATE as string, NUMERIC as string, TIMESTAMPTZ as Date. */ export interface RaceRow { id: string; race_date: string | Date; title: string; distance_km: string; status: string | null; official_url: string | null; cover_image_url: string | null; start_time: string | null; cluster_schedule: string | null; bib_pickup: string | null; bib_number: string | null; finish_time: string | null; finish_place: string | null; notes: string | null; created_at: Date; updated_at: Date | null; } /** API shape (camelCase). */ export interface RaceDto { id: string; date: string; title: string; distanceKm: number; status: string | null; officialUrl: string | null; coverImageUrl: string | null; startTime: string | null; clusterSchedule: string | null; bibPickup: string | null; bibNumber: string | null; finishTime: string | null; finishPlace: string | null; notes: string | null; createdAt: string; updatedAt: string | null; } function toISOString(value: Date | string): string { return value instanceof Date ? value.toISOString() : String(value); } /** DATE column may arrive as string or Date; API always exposes YYYY-MM-DD for the calendar day. */ function raceDateToApiValue(value: string | Date): string { if (typeof value === "string") { const m = value.match(/^(\d{4}-\d{2}-\d{2})/); return m ? m[1]! : value; } const y = value.getFullYear(); const mo = String(value.getMonth() + 1).padStart(2, "0"); const day = String(value.getDate()).padStart(2, "0"); return `${y}-${mo}-${day}`; } /** Convert a DB row to the API DTO (camelCase). */ export function rowToDto(row: RaceRow): RaceDto { return { id: row.id, date: raceDateToApiValue(row.race_date), title: row.title, distanceKm: parseFloat(row.distance_km), status: row.status, officialUrl: row.official_url, coverImageUrl: row.cover_image_url ?? null, startTime: row.start_time, clusterSchedule: row.cluster_schedule, bibPickup: row.bib_pickup, bibNumber: row.bib_number, finishTime: row.finish_time, finishPlace: row.finish_place, notes: row.notes, createdAt: toISOString(row.created_at), updatedAt: row.updated_at ? toISOString(row.updated_at) : null, }; } /** Map incoming camelCase body fields to snake_case column names. */ const FIELD_MAP: Record = { date: "race_date", title: "title", distanceKm: "distance_km", status: "status", officialUrl: "official_url", coverImageUrl: "cover_image_url", startTime: "start_time", clusterSchedule: "cluster_schedule", bibPickup: "bib_pickup", bibNumber: "bib_number", finishTime: "finish_time", finishPlace: "finish_place", notes: "notes", }; /** * Convert a partial camelCase body into { columns, values, placeholders } * suitable for SQL INSERT / UPDATE. */ export function bodyToColumns(body: Record) { 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 }; }