feat: complete race history sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
This commit is contained in:
2
backend/migrations/007_cover_image_source.sql
Normal file
2
backend/migrations/007_cover_image_source.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE races
|
||||
ADD COLUMN IF NOT EXISTS cover_image_manual BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
55
backend/src/backfillRaceCovers.ts
Normal file
55
backend/src/backfillRaceCovers.ts
Normal 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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] };
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user