feat: complete race history sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

This commit is contained in:
Vakanaut
2026-07-13 08:31:12 +03:00
parent 46998a1a67
commit a8b87fc6b1
22 changed files with 725 additions and 288 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE races
ADD COLUMN IF NOT EXISTS cover_image_manual BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-backend",
"version": "1.5.3",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.5.3",
"version": "1.6.0",
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",

View File

@@ -1,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.5.3",
"version": "1.6.0",
"private": true,
"scripts": {
"build": "tsc",
@@ -8,6 +8,7 @@
"start": "node dist/index.js",
"db:migrate": "ts-node src/migrate.ts",
"seed": "ts-node src/seed.ts",
"backfill:covers": "ts-node src/backfillRaceCovers.ts",
"test": "tsx --import ./test/setup.ts --test test/app.test.ts"
},
"dependencies": {

View File

@@ -0,0 +1,55 @@
import { pool } from "./db";
import { extractRaceCoverImage } from "./raceCoverImage";
type Candidate = { id: string; official_url: string };
export type CoverBackfillStats = {
candidates: number;
restored: number;
skipped: number;
};
export async function backfillRaceCovers(): Promise<CoverBackfillStats> {
const { rows } = await pool.query<Candidate>(
`SELECT id, official_url
FROM races
WHERE cover_image_url IS NULL
AND official_url IS NOT NULL
AND BTRIM(official_url) <> ''
ORDER BY race_date ASC, id ASC`,
);
const stats: CoverBackfillStats = { candidates: rows.length, restored: 0, skipped: 0 };
// ponytail: sequential network calls keep memory and outbound load bounded; parallelize per batch if volume requires it.
for (const row of rows) {
const coverImageUrl = await extractRaceCoverImage(row.official_url);
if (!coverImageUrl) {
stats.skipped += 1;
continue;
}
const result = await pool.query(
`UPDATE races
SET cover_image_url = $1, cover_image_manual = FALSE, updated_at = NOW()
WHERE id = $2 AND cover_image_url IS NULL`,
[coverImageUrl, row.id],
);
if (result.rowCount === 1) {
stats.restored += 1;
} else {
stats.skipped += 1;
}
}
return stats;
}
if (require.main === module) {
backfillRaceCovers()
.then((stats) => console.log(JSON.stringify(stats)))
.catch((error: unknown) => {
console.error("[backfill:covers] FAILED:", error instanceof Error ? error.message : error);
process.exitCode = 1;
})
.finally(() => pool.end());
}

View File

@@ -12,6 +12,7 @@ export interface RaceRow {
status: string | null;
official_url: string | null;
cover_image_url: string | null;
cover_image_manual: boolean;
start_time: string | null;
cluster_schedule: string | null;
bib_pickup: string | null;

View File

@@ -20,7 +20,7 @@ import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddle
import { isValidPassword, normalizeEmail } from "../security";
import { verifyTurnstileToken } from "../turnstile";
const router = Router();
const router: Router = Router();
const genericOk = { ok: true };
const genericAuthError = { error: "invalid_credentials", details: ["Invalid email or password"] };

View File

@@ -2,7 +2,7 @@ import { Router, Request, Response } from "express";
import { checkDbConnection } from "../db";
import { getBackendVersion } from "../version";
const router = Router();
const router: Router = Router();
router.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok", version: getBackendVersion() });

View File

@@ -5,7 +5,7 @@ import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
import { extractRaceCoverImage } from "../raceCoverImage";
import { requireAuth } from "../authMiddleware";
const router = Router();
const router: Router = Router();
router.use(requireAuth);
type ValidationErrorBody = {
@@ -136,12 +136,77 @@ function parseOptionalIntegerQuery(
return { value: parsed };
}
function parseOptionalNumberQuery(
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 a number` };
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return { error: `${fieldName} must be a number` };
}
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 };
}
function parseOptionalDateQuery(value: unknown, fieldName: string): { value?: string; error?: string } {
if (value == null) {
return {};
}
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return { error: `${fieldName} must be YYYY-MM-DD` };
}
const parsed = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value) {
return { error: `${fieldName} must be a valid calendar date` };
}
return { value };
}
/* ─── 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[];
const statusResult = req.query.status == null
? {}
: typeof req.query.status === "string" && ["planned", "registered", "completed"].includes(req.query.status)
? { value: req.query.status }
: { error: "status must be planned, registered or completed" };
const dateFromResult = parseOptionalDateQuery(req.query.dateFrom, "dateFrom");
const dateToResult = parseOptionalDateQuery(req.query.dateTo, "dateTo");
const distanceMinResult = parseOptionalNumberQuery(req.query.distanceMin, "distanceMin", 0.001, 999.999);
const distanceMaxResult = parseOptionalNumberQuery(req.query.distanceMax, "distanceMax", 0.001, 999.999);
const details = [
yearResult.error,
monthResult.error,
statusResult.error,
dateFromResult.error,
dateToResult.error,
distanceMinResult.error,
distanceMaxResult.error,
].filter(Boolean) as string[];
if (dateFromResult.value && dateToResult.value && dateFromResult.value > dateToResult.value) {
details.push("dateFrom must not be after dateTo");
}
if (distanceMinResult.value != null && distanceMaxResult.value != null && distanceMinResult.value > distanceMaxResult.value) {
details.push("distanceMin must not be greater than distanceMax");
}
if (details.length > 0) {
validationError(res, details);
@@ -164,6 +229,26 @@ router.get("/races", async (req: Request, res: Response) => {
conditions.push(`EXTRACT(MONTH FROM race_date) = $${idx++}`);
params.push(monthResult.value);
}
if (statusResult.value != null) {
conditions.push(`status = $${idx++}`);
params.push(statusResult.value);
}
if (dateFromResult.value != null) {
conditions.push(`race_date >= $${idx++}`);
params.push(dateFromResult.value);
}
if (dateToResult.value != null) {
conditions.push(`race_date <= $${idx++}`);
params.push(dateToResult.value);
}
if (distanceMinResult.value != null) {
conditions.push(`distance_km >= $${idx++}`);
params.push(distanceMinResult.value);
}
if (distanceMaxResult.value != null) {
conditions.push(`distance_km <= $${idx++}`);
params.push(distanceMaxResult.value);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `SELECT * FROM races ${where} ORDER BY race_date ASC`;
@@ -220,6 +305,8 @@ router.post("/races", async (req: Request, res: Response) => {
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
columns.unshift("owner_user_id");
values.unshift(req.auth!.user.id);
columns.push("cover_image_manual");
values.push(hasManualCover);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
@@ -251,15 +338,43 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
validationError(res, schemaDetails(parsed.error));
return;
}
const { columns, values } = bodyToColumns(parsed.data as Record<string, unknown>);
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`);
values.push(id);
values.push(req.auth!.user.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`;
try {
const currentResult = await pool.query<Pick<RaceRow, "official_url" | "cover_image_url" | "cover_image_manual">>(
"SELECT official_url, cover_image_url, cover_image_manual FROM races WHERE id = $1 AND owner_user_id = $2",
[id, req.auth!.user.id],
);
if (currentResult.rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });
return;
}
const payload = { ...parsed.data };
const officialUrlChanged = Object.prototype.hasOwnProperty.call(payload, "officialUrl");
const current = currentResult.rows[0]!;
const hasCoverField = Object.prototype.hasOwnProperty.call(payload, "coverImageUrl");
const sameAsExistingCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl === current.cover_image_url;
const manualCoverChanged = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "" && !sameAsExistingCover;
let coverImageManual = current.cover_image_manual;
if (hasCoverField && !sameAsExistingCover) {
coverImageManual = manualCoverChanged;
}
const officialUrl = payload.officialUrl === undefined ? current.official_url : payload.officialUrl;
if (officialUrlChanged && !current.cover_image_manual && !manualCoverChanged && typeof officialUrl === "string" && officialUrl.trim() !== "") {
payload.coverImageUrl = await extractRaceCoverImage(officialUrl);
coverImageManual = false;
}
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
if (hasCoverField || officialUrlChanged) {
columns.push("cover_image_manual");
values.push(coverImageManual);
}
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`);
values.push(id);
values.push(req.auth!.user.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`;
const { rows } = await pool.query<RaceRow>(sql, values);
if (rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { after, test } from "node:test";
import request from "supertest";
import { buildHelmetOptions, createApp } from "../src/app";
import { backfillRaceCovers } from "../src/backfillRaceCovers";
import {
cleanupExpiredAuthRows,
createResetToken,
@@ -161,6 +162,29 @@ test("GET /api/races accepts year and month", async () => {
assert.ok(Array.isArray(res.body));
});
test("GET /api/races filters by status, date and distance", async () => {
const { agent, csrfToken } = await authAgent();
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-05-01-filter-planned", date: "2026-05-01", title: "Planned 5", distanceKm: 5, status: "planned",
}).expect(201);
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-06-01-filter-completed", date: "2026-06-01", title: "Completed 10", distanceKm: 10, status: "completed",
}).expect(201);
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-07-01-filter-completed", date: "2026-07-01", title: "Completed 21", distanceKm: 21.1, status: "completed",
}).expect(201);
const res = await agent.get("/api/races?status=completed&dateFrom=2026-06-01&dateTo=2026-06-30&distanceMax=10").expect(200);
assert.deepEqual(res.body.map((race: { title: string }) => race.title), ["Completed 10"]);
});
test("GET /api/races rejects inverted filter ranges", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/races?dateFrom=2026-07-01&dateTo=2026-06-01").expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(res.body.details.some((detail: string) => detail.includes("dateFrom")));
});
test("GET /api/races/:id rejects a non-UUID id", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/races/does-not-exist").expect(400);
@@ -460,6 +484,39 @@ test("POST /api/races stores manual coverImageUrl", async () => {
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("PATCH /api/races refreshes an automatic cover but preserves a manual cover", async () => {
const originalFetch = globalThis.fetch;
let imageName = "first.jpg";
globalThis.fetch = async () => new Response(`<meta property="og:image" content="/${imageName}">`, {
status: 200,
headers: { "content-type": "text/html" },
});
try {
const { agent, csrfToken } = await authAgent();
const created = await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-06-06-auto-refresh", date: "2026-06-06", title: "Auto Refresh", distanceKm: 10, officialUrl: "https://example.com/first",
}).expect(201);
imageName = "second.jpg";
const refreshed = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
officialUrl: "https://example.com/second",
}).expect(200);
assert.equal(refreshed.body.coverImageUrl, "https://example.com/second.jpg");
const manual = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
officialUrl: "https://example.com/third", coverImageUrl: "https://example.com/manual.jpg",
}).expect(200);
imageName = "fourth.jpg";
const preserved = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
officialUrl: "https://example.com/fourth",
}).expect(200);
assert.equal(manual.body.coverImageUrl, "https://example.com/manual.jpg");
assert.equal(preserved.body.coverImageUrl, "https://example.com/manual.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/races rejects unknown and malformed fields", async () => {
const { agent, csrfToken } = await authAgent();
const res = await agent
@@ -526,6 +583,58 @@ test("POST /api/races auto extracts coverImageUrl from officialUrl", async () =>
}
});
test("PATCH /api/races auto extracts a missing cover when officialUrl changes", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response("", { status: 404 });
try {
const { agent, csrfToken } = await authAgent();
const created = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({ slug: "2026-06-04-patch-cover", date: "2026-06-04", title: "Patch Cover", distanceKm: 10 })
.expect(201);
globalThis.fetch = async () => new Response('<meta property="og:image" content="/patched.jpg">', {
status: 200,
headers: { "content-type": "text/html" },
});
const updated = await agent
.patch(`/api/races/${created.body.id}`)
.set("X-CSRF-Token", csrfToken)
.send({ officialUrl: "https://example.com/patched" })
.expect(200);
assert.equal(updated.body.coverImageUrl, "https://example.com/patched.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("cover backfill restores only races without a cover", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response('<meta property="og:image" content="/backfill.jpg">', {
status: 200,
headers: { "content-type": "text/html" },
});
try {
const { agent, csrfToken } = await authAgent();
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-06-05-backfill", date: "2026-06-05", title: "Backfill", distanceKm: 10,
}).expect(201);
await pool.query("UPDATE races SET official_url = $1 WHERE slug = $2", ["https://example.com/backfill", "2026-06-05-backfill"]);
const stats = await backfillRaceCovers();
assert.ok(stats.candidates >= 1);
assert.ok(stats.restored >= 1);
const result = await pool.query<{ cover_image_url: string }>("SELECT cover_image_url FROM races WHERE slug = $1", ["2026-06-05-backfill"]);
assert.equal(result.rows[0]?.cover_image_url, "https://example.com/backfill.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/races succeeds when cover extraction fails", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {