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:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ─── PostgreSQL ───────────────────────────────────────────────
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=calendar_run
|
||||||
|
DB_USER=calendar_user
|
||||||
|
DB_PASSWORD=calendar_pass
|
||||||
|
|
||||||
|
# ─── Backend API ──────────────────────────────────────────────
|
||||||
|
# Port the API server listens on
|
||||||
|
API_PORT=3001
|
||||||
|
|
||||||
|
# ─── CORS ─────────────────────────────────────────────────────
|
||||||
|
# Allowed origin for the frontend (Vite dev server default)
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# ─── Frontend (Vite) ─────────────────────────────────────────
|
||||||
|
# Public URL of the API, used in SPA code via import.meta.env
|
||||||
|
VITE_API_BASE_URL=http://localhost:3001
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
16
backend/migrations/001_create_races.sql
Normal file
16
backend/migrations/001_create_races.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS races (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
race_date DATE NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
distance_km NUMERIC(6,3) NOT NULL,
|
||||||
|
status TEXT CHECK (status IN ('planned', 'completed')),
|
||||||
|
official_url TEXT,
|
||||||
|
start_time TEXT,
|
||||||
|
cluster_schedule TEXT,
|
||||||
|
bib_pickup TEXT,
|
||||||
|
bib_number TEXT,
|
||||||
|
finish_time TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
1360
backend/package-lock.json
generated
Normal file
1360
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
backend/package.json
Normal file
27
backend/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "calendar-run-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "ts-node src/index.ts",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:migrate": "ts-node src/migrate.ts",
|
||||||
|
"seed": "ts-node src/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"csv-parse": "^5.6.0",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"pg": "^8.13.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.12.0",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
19
backend/tsconfig.json
Normal file
19
backend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME:-calendar_run}
|
||||||
|
POSTGRES_USER: ${DB_USER:-calendar_user}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-calendar_pass}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
234
docs/backend-api-for-frontend.md
Normal file
234
docs/backend-api-for-frontend.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# Backend API — шпаргалка для фронтенда
|
||||||
|
|
||||||
|
## 1. Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_API_BASE_URL=http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
В коде SPA: `import.meta.env.VITE_API_BASE_URL`.
|
||||||
|
|
||||||
|
## 2. CORS
|
||||||
|
|
||||||
|
В dev-режиме бэкенд ожидает переменную:
|
||||||
|
|
||||||
|
```
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Разрешены методы `GET`, `POST`, `PATCH`, `DELETE` и заголовок `Content-Type: application/json`.
|
||||||
|
|
||||||
|
## 3. Эндпоинты
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Liveness-проверка (без обращения к БД).
|
||||||
|
|
||||||
|
**Ответ 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /ready`
|
||||||
|
|
||||||
|
Readiness-проверка (проверяет подключение к БД).
|
||||||
|
|
||||||
|
**Ответ 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ready", "db": "connected" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ 503:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "database_unavailable", "db": "disconnected" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /races`
|
||||||
|
|
||||||
|
Список забегов, отсортированных по дате.
|
||||||
|
|
||||||
|
**Query-параметры (опциональные):**
|
||||||
|
|
||||||
|
| Параметр | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `year` | number | Фильтр по году (напр. `2026`) |
|
||||||
|
| `month` | number | Фильтр по месяцу (1–12) |
|
||||||
|
|
||||||
|
- Без параметров — возвращает все забеги.
|
||||||
|
- Можно указать только `year`, только `month` или оба.
|
||||||
|
- `month` без `year` фильтрует по месяцу **всех** лет.
|
||||||
|
|
||||||
|
**Пример запроса:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /races?year=2026&month=5
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "2026-05-03-kazanskii-marafon",
|
||||||
|
"date": "2026-05-03",
|
||||||
|
"title": "Казанский марафон",
|
||||||
|
"distanceKm": 42.195,
|
||||||
|
"status": "planned",
|
||||||
|
"officialUrl": null,
|
||||||
|
"startTime": null,
|
||||||
|
"clusterSchedule": null,
|
||||||
|
"bibPickup": null,
|
||||||
|
"bibNumber": null,
|
||||||
|
"finishTime": null,
|
||||||
|
"notes": null,
|
||||||
|
"createdAt": "2026-03-31T12:00:00.000Z",
|
||||||
|
"updatedAt": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /races/:id`
|
||||||
|
|
||||||
|
Одна запись по `id`.
|
||||||
|
|
||||||
|
**Ответ 200:** объект `Race` (см. модель ниже).
|
||||||
|
|
||||||
|
**Ответ 404:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "not_found" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /races`
|
||||||
|
|
||||||
|
Создание забега.
|
||||||
|
|
||||||
|
**Тело запроса (JSON):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "2026-06-01-my-race",
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"title": "Мой забег",
|
||||||
|
"distanceKm": 10,
|
||||||
|
"status": "planned",
|
||||||
|
"officialUrl": "https://example.com",
|
||||||
|
"startTime": "09:30",
|
||||||
|
"clusterSchedule": null,
|
||||||
|
"bibPickup": null,
|
||||||
|
"bibNumber": null,
|
||||||
|
"finishTime": null,
|
||||||
|
"notes": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Обязательные поля:** `id`, `date`, `title`, `distanceKm`.
|
||||||
|
|
||||||
|
**Ответ 201:** созданный объект `Race`.
|
||||||
|
|
||||||
|
**Ответ 400:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "validation_error", "details": ["Fields id, date, title, distanceKm are required"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ 409:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "conflict", "details": ["Race with this id already exists"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `PATCH /races/:id`
|
||||||
|
|
||||||
|
Частичное обновление — передавать **только** изменяемые поля.
|
||||||
|
|
||||||
|
**Тело запроса (JSON):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"finishTime": "1:45:30",
|
||||||
|
"bibNumber": "1234",
|
||||||
|
"status": "completed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `notes`.
|
||||||
|
|
||||||
|
**Ответ 200:** обновлённый объект `Race`.
|
||||||
|
|
||||||
|
**Ответ 400:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "validation_error", "details": ["No updatable fields provided"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ 404:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "not_found" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DELETE /races/:id`
|
||||||
|
|
||||||
|
Удаление забега.
|
||||||
|
|
||||||
|
**Ответ 204:** пустое тело.
|
||||||
|
|
||||||
|
**Ответ 404:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "not_found" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Модель `Race` (camelCase)
|
||||||
|
|
||||||
|
| Поле | Тип | POST обяз. | PATCH | Описание |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `id` | string | да | — | Стабильный ключ, напр. `2026-05-03-kazan-marathon` |
|
||||||
|
| `date` | string | да | да | `YYYY-MM-DD` |
|
||||||
|
| `title` | string | да | да | Название забега |
|
||||||
|
| `distanceKm` | number | да | да | Дистанция в км |
|
||||||
|
| `status` | string \| null | нет | да | `"planned"` / `"completed"` |
|
||||||
|
| `officialUrl` | string \| null | нет | да | URL организатора |
|
||||||
|
| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` |
|
||||||
|
| `clusterSchedule` | string \| null | нет | да | Расписание кластеров |
|
||||||
|
| `bibPickup` | string \| null | нет | да | Выдача номеров |
|
||||||
|
| `bibNumber` | string \| null | нет | да | Стартовый номер |
|
||||||
|
| `finishTime` | string \| null | нет | да | Финишное время `H:MM:SS` |
|
||||||
|
| `notes` | string \| null | нет | да | Заметки |
|
||||||
|
| `createdAt` | string | — | — | ISO timestamp (read-only) |
|
||||||
|
| `updatedAt` | string \| null | — | — | ISO timestamp (read-only) |
|
||||||
|
|
||||||
|
## 5. Фильтрация списка (`GET /races`)
|
||||||
|
|
||||||
|
- **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`.
|
||||||
|
- **`month`** — целое число 1–12, фильтрует по `EXTRACT(MONTH FROM race_date)`.
|
||||||
|
- Параметры можно комбинировать (`?year=2026&month=5`) или указывать по одному.
|
||||||
|
- Без параметров — все забеги без ограничения (пагинация пока не реализована).
|
||||||
|
- Если по фильтру нет результатов — пустой массив `[]`, статус `200`.
|
||||||
|
|
||||||
|
## 6. Идемпотентность seed
|
||||||
|
|
||||||
|
Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert** по полю `id` (`INSERT … ON CONFLICT (id) DO UPDATE`). Источник данных — `import/races_2026_calendar.csv` из корня репозитория. Повторный запуск безопасен.
|
||||||
|
|
||||||
|
## 7. Поведение при недоступной БД
|
||||||
|
|
||||||
|
- `GET /health` — всегда `200` (не проверяет БД).
|
||||||
|
- `GET /ready` — `503 { "error": "database_unavailable", "db": "disconnected" }`.
|
||||||
|
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
|
||||||
|
- В логах сервера: строка ошибки с контекстом маршрута.
|
||||||
107
docs/backend.md
Normal file
107
docs/backend.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Backend — эксплуатация
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- **Node.js LTS** + TypeScript
|
||||||
|
- **Express** (HTTP-фреймворк)
|
||||||
|
- **pg** (PostgreSQL клиент)
|
||||||
|
- **csv-parse** (парсинг CSV для seed)
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### 1. Поднять PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# из корня проекта
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Параметры подключения берутся из `.env` (см. `.env.example` в корне).
|
||||||
|
|
||||||
|
### 2. Установить зависимости
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Создать `.env`
|
||||||
|
|
||||||
|
Скопировать `.env.example` из корня проекта и при необходимости отредактировать:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ../.env.example ../.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Миграции
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Миграционный раннер — собственный скрипт `src/migrate.ts`:
|
||||||
|
- хранит историю применённых файлов в таблице `_migrations`;
|
||||||
|
- идемпотентный — повторный запуск не применяет уже выполненные миграции;
|
||||||
|
- файлы миграций: `backend/migrations/*.sql`, применяются в алфавитном порядке.
|
||||||
|
|
||||||
|
### 5. Seed (начальный набор данных)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run seed
|
||||||
|
```
|
||||||
|
|
||||||
|
- Читает `import/races_2026_calendar.csv` из корня репо.
|
||||||
|
- Генерирует стабильный `id` в формате `{date}-{slug}`.
|
||||||
|
- Выполняет **upsert** (`INSERT … ON CONFLICT DO UPDATE`) — безопасно для повторного запуска.
|
||||||
|
|
||||||
|
### 6. Запуск API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # dev-режим через ts-node
|
||||||
|
npm run build # компиляция в dist/
|
||||||
|
npm start # запуск из dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
API слушает порт из `API_PORT` (по умолчанию `3001`).
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Описание | По умолчанию |
|
||||||
|
|---|---|---|
|
||||||
|
| `DB_HOST` | Хост PostgreSQL | — (обязательна) |
|
||||||
|
| `DB_PORT` | Порт PostgreSQL | — (обязательна) |
|
||||||
|
| `DB_NAME` | Имя базы данных | — (обязательна) |
|
||||||
|
| `DB_USER` | Пользователь БД | — (обязательна) |
|
||||||
|
| `DB_PASSWORD` | Пароль БД | — (обязательна) |
|
||||||
|
| `API_PORT` | Порт API-сервера | `3001` |
|
||||||
|
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
|
||||||
|
|
||||||
|
При отсутствии любой из `DB_*` процесс падает при старте с сообщением `Missing required environment variable: <NAME>`.
|
||||||
|
|
||||||
|
## Поведение при недоступной БД
|
||||||
|
|
||||||
|
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
|
||||||
|
- **`GET /health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
|
||||||
|
- **`GET /ready`** — пробует подключиться к БД; возвращает `200` если ОК, `503 { "error": "database_unavailable" }` если нет.
|
||||||
|
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
|
||||||
|
|
||||||
|
## Структура каталога
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── migrations/
|
||||||
|
│ └── 001_create_races.sql
|
||||||
|
├── src/
|
||||||
|
│ ├── config.ts # загрузка и валидация env
|
||||||
|
│ ├── db.ts # pg Pool
|
||||||
|
│ ├── index.ts # точка входа Express
|
||||||
|
│ ├── migrate.ts # раннер миграций
|
||||||
|
│ ├── seed.ts # разовый импорт CSV
|
||||||
|
│ ├── mappers/
|
||||||
|
│ │ └── race.ts # snake_case ↔ camelCase
|
||||||
|
│ └── routes/
|
||||||
|
│ ├── health.ts # /health, /ready
|
||||||
|
│ └── races.ts # CRUD /races
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user