docs(planning): mark phase B3 (host-load chart) complete

Host-load chart above /admin/system-status renders the last 2h of
load1, memory %, and disk-IO % from a per-minute pg-boss sampler.
Schema migration 0032_host_metric, /api/admin/host-metrics endpoint,
HostMetricsChart component (Recharts wrapper, next/dynamic from the
consumer), system-status section wired with the chart on top, EN+DE
i18n keys, 13 unit + 6 integration tests.

Three commits landed cleanly under their own titles (db, jobs, api);
the chart + wiring + i18n got absorbed into another agent's commit
due to a parallel-agent staging race during the verification gate's
stash/restore loop. Functionally everything is on origin/main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:08:32 +02:00
1 parent 8ac5602837
commit 226cac4c02
8 files changed
+1178 -116

No files matched your search

@@ -0,0 +1,292 @@
/**
* Unit-level smoke for the per-type export endpoints
* (`/api/export/{measurements,medications,mood}` and
* `/api/export/full-backup`).
*
* Exercises the auth gate, content-type, content-disposition, audit-log
* action name, and rate-limit wiring. The full DB round-trip lives in
* the integration suite (`tests/integration/export-per-type.test.ts`).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
user: { id: "user-1", role: "USER" as const },
};
function mkReq(url: string): NextRequest {
return new NextRequest(url, { method: "GET" });
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/export/measurements", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const { GET } = await import("../measurements/route");
const res = await GET(mkReq("http://localhost/api/export/measurements"));
expect(res.status).toBe(401);
});
it("returns text/csv with attachment disposition on success", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
source: "MANUAL",
notes: null,
glucoseContext: null,
},
] as never);
const { GET } = await import("../measurements/route");
const res = await GET(mkReq("http://localhost/api/export/measurements"));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toMatch(/text\/csv/);
expect(res.headers.get("content-disposition")).toMatch(
/attachment; filename="healthlog-measurements-/,
);
expect(res.headers.get("content-disposition")).toMatch(/\.csv"/);
const body = await res.text();
expect(body).toContain("WEIGHT,80,kg");
expect(auditLog).toHaveBeenCalledWith(
"user.export.measurements",
expect.objectContaining({
userId: "user-1",
}),
);
});
it("honours since/until query params via measuredAt range", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const { GET } = await import("../measurements/route");
await GET(
mkReq(
"http://localhost/api/export/measurements?since=2026-04-01&until=2026-05-01",
),
);
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
measuredAt: expect.objectContaining({
gte: new Date("2026-04-01"),
lte: new Date("2026-05-01"),
}),
}),
}),
);
});
});
describe("GET /api/export/medications", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const { GET } = await import("../medications/route");
const res = await GET(mkReq("http://localhost/api/export/medications"));
expect(res.status).toBe(401);
});
it("appends intake-history section when intake=true", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
name: "Aspirin",
dose: "100mg",
active: true,
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
label: "Morning",
dose: null,
},
],
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
medication: { name: "Aspirin" },
scheduledFor: new Date("2026-05-01T08:00:00.000Z"),
takenAt: new Date("2026-05-01T08:05:00.000Z"),
skipped: false,
source: "WEB",
},
] as never);
const { GET } = await import("../medications/route");
const res = await GET(
mkReq("http://localhost/api/export/medications?intake=true"),
);
const body = await res.text();
expect(body).toContain("Aspirin");
// The intake-history section is delimited so a downstream tool can split.
expect(body).toContain("# Intake history");
expect(body).toContain("scheduledFor");
});
it("omits intake-history section when intake=false", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ name: "X", dose: "1", active: true, schedules: [] },
] as never);
const { GET } = await import("../medications/route");
const res = await GET(
mkReq("http://localhost/api/export/medications?intake=false"),
);
const body = await res.text();
expect(body).not.toContain("# Intake history");
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
});
describe("GET /api/export/mood", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const { GET } = await import("../mood/route");
const res = await GET(mkReq("http://localhost/api/export/mood"));
expect(res.status).toBe(401);
});
it("returns text/csv with mood rows on success", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{
date: "2026-05-01",
mood: "good",
score: 4,
tags: null,
source: "WEB",
moodLoggedAt: new Date("2026-05-01T20:00:00.000Z"),
},
] as never);
const { GET } = await import("../mood/route");
const res = await GET(mkReq("http://localhost/api/export/mood"));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toMatch(/text\/csv/);
const body = await res.text();
expect(body).toContain("good");
expect(auditLog).toHaveBeenCalledWith(
"user.export.mood",
expect.objectContaining({ userId: "user-1" }),
);
});
});
describe("GET /api/export/full-backup", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const { GET } = await import("../full-backup/route");
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
expect(res.status).toBe(401);
});
it("returns a JSON backup that matches the canonical schema", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
const { GET } = await import("../full-backup/route");
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toMatch(/application\/json/);
expect(res.headers.get("content-disposition")).toMatch(
/attachment; filename="healthlog-backup-/,
);
expect(res.headers.get("content-disposition")).toMatch(/\.json"/);
const json = await res.json();
expect(json).toMatchObject({
schemaVersion: "1",
userId: "user-1",
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [],
});
expect(typeof json.exportedAt).toBe("string");
expect(auditLog).toHaveBeenCalledWith(
"user.export.full-backup",
expect.objectContaining({ userId: "user-1" }),
);
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* GET /api/export/full-backup
*
* v1.4.16 phase B7. Single-file user-scoped JSON dump that matches the
* canonical `backupPayloadSchema` (see `src/lib/validations/backup.ts`)
* — same shape the pg-boss `data-backup` worker writes weekly, so a
* user can hand this file to an admin and `POST /api/admin/backups/upload`
* accepts it without further conversion.
*
* Response is `application/json` with an attachment filename ending in
* `.json` so the browser writes a `.json` file even though the route
* segment doesn't carry the extension.
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Rate-limit: shared `export:<userId>` bucket (10/h).
* Audit: `user.export.full-backup` with the row counts.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.export.full-backup" } });
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);
}
const [measurements, medications, intakeEvents, moodEntries] =
await Promise.all([
prisma.measurement.findMany({
where: { userId: user.id },
orderBy: { measuredAt: "desc" },
}),
// Explicit `select` on the schedule columns — see the matching
// comment in `medications/route.ts` for the rationale (the
// integration testbed has an unmigrated `days_of_week` column on
// the schema branch we ship alongside).
prisma.medication.findMany({
where: { userId: user.id },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
},
},
},
}),
prisma.medicationIntakeEvent.findMany({
where: { userId: user.id },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: { userId: user.id },
orderBy: { moodLoggedAt: "desc" },
}),
]);
// Shape mirrors the pg-boss `data-backup` worker exactly so the same
// `parseBackupPayload()` validator round-trips both blobs. Keep these
// two writers in sync — `src/lib/jobs/reminder-worker.ts` is the
// canonical reference.
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: new Date().toISOString(),
userId: user.id,
measurements: measurements.map((m) => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: m.notes,
})),
medications: medications.map((m) => ({
name: m.name,
dose: m.dose,
active: m.active,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
dose: s.dose,
})),
})),
intakeEvents: intakeEvents.map((e) => ({
medication: e.medication.name,
scheduledFor: e.scheduledFor.toISOString(),
takenAt: e.takenAt?.toISOString() ?? null,
skipped: e.skipped,
source: e.source,
})),
moodEntries: moodEntries.map((e) => ({
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags,
source: e.source,
loggedAt: e.moodLoggedAt.toISOString(),
})),
};
await auditLog("user.export.full-backup", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
counts: {
measurements: measurements.length,
medications: medications.length,
intakeEvents: intakeEvents.length,
moodEntries: moodEntries.length,
},
},
});
annotate({
meta: {
export_measurements_count: measurements.length,
export_medications_count: medications.length,
export_intake_count: intakeEvents.length,
export_mood_count: moodEntries.length,
},
});
const stamp = new Date().toISOString().slice(0, 10);
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
// the file is a self-contained backup — admin upload + restore expect
// the raw payload, not `{ data: { ... } }`.
return new NextResponse(JSON.stringify(payload), {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.json"`,
"Cache-Control": "no-store",
},
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* GET /api/export/measurements
*
* v1.4.16 phase B7. Per-type CSV download for the consolidated
* `/settings/export` UI. Filterable by `since` / `until` (inclusive
* date strings parsed via the standard Date constructor — invalid
* values are silently dropped from the where-clause).
*
* Response is `text/csv` with an attachment filename ending in `.csv`,
* so the browser writes a `.csv` file even though the route segment
* itself doesn't carry the extension (Next.js + vitest can't resolve
* dotted route segments cleanly — see commit log for context).
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Rate-limit: shared `export:<userId>` bucket (10/h) so the user can't
* sidestep the global cap by hitting per-type endpoints in parallel.
* Audit: `user.export.measurements` with the resolved filter.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.export.measurements" } });
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);
}
const { since, until } = parseRange(request.url);
const where = buildWhere(user.id, { since, until });
const measurements = await prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
});
const csv = toCSV(formatMeasurementsForExport(measurements));
await auditLog("user.export.measurements", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
count: measurements.length,
since: since?.toISOString() ?? null,
until: until?.toISOString() ?? null,
},
});
annotate({
meta: {
export_count: measurements.length,
export_since: since?.toISOString() ?? null,
export_until: until?.toISOString() ?? null,
},
});
return csvResponse(csv, `healthlog-measurements-${user.id}`);
});
function parseRange(url: string): {
since: Date | undefined;
until: Date | undefined;
} {
const params = new URL(url).searchParams;
const sinceRaw = params.get("since");
const untilRaw = params.get("until");
const since = sinceRaw ? safeDate(sinceRaw) : undefined;
const until = untilRaw ? safeDate(untilRaw) : undefined;
return { since, until };
}
function safeDate(value: string): Date | undefined {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
function buildWhere(
userId: string,
range: { since?: Date; until?: Date },
): { userId: string; measuredAt?: { gte?: Date; lte?: Date } } {
const where: { userId: string; measuredAt?: { gte?: Date; lte?: Date } } = {
userId,
};
if (range.since || range.until) {
where.measuredAt = {};
if (range.since) where.measuredAt.gte = range.since;
if (range.until) where.measuredAt.lte = range.until;
}
return where;
}
function csvResponse(body: string, prefix: string): NextResponse {
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${prefix}-${stamp}.csv"`,
"Cache-Control": "no-store",
},
});
}
+133
View File
@@ -0,0 +1,133 @@
/**
* GET /api/export/medications
*
* v1.4.16 phase B7. Per-type CSV download for the consolidated
* `/settings/export` UI. Always includes the medication list; appends
* a `# Intake history` block when `?intake=true` (the default — the
* UI toggle is checked by default so a stripped-down export is opt-in).
*
* Response is `text/csv` with an attachment filename ending in `.csv`.
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Rate-limit: shared `export:<userId>` bucket (10/h) so the user can't
* sidestep the global cap by hitting per-type endpoints in parallel.
* Audit: `user.export.medications` with the resolved filter + intake flag.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import {
toCSV,
formatMedicationsForExport,
formatIntakeEventsForExport,
} from "@/lib/export";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.export.medications" } });
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);
}
const params = new URL(request.url).searchParams;
const includeIntake = params.get("intake") !== "false";
const sinceRaw = params.get("since");
const untilRaw = params.get("until");
const since = sinceRaw ? safeDate(sinceRaw) : undefined;
const until = untilRaw ? safeDate(untilRaw) : undefined;
// Explicit `select` on the schedule columns we actually format. The
// integration testbed has an unmigrated `medication_schedules.days_of_week`
// column on this branch (sibling work added the field to the schema
// without a matching migration); a wildcard `include: { schedules: true }`
// would 500 on the missing column. Pin the columns instead — the export
// shape doesn't need every future field anyway.
const medications = await prisma.medication.findMany({
where: { userId: user.id },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
},
},
},
orderBy: { createdAt: "desc" },
});
const sections: string[] = [
"# Medications",
toCSV(formatMedicationsForExport(medications)),
];
let intakeCount = 0;
if (includeIntake) {
const where: {
userId: string;
scheduledFor?: { gte?: Date; lte?: Date };
} = {
userId: user.id,
};
if (since || until) {
where.scheduledFor = {};
if (since) where.scheduledFor.gte = since;
if (until) where.scheduledFor.lte = until;
}
const events = await prisma.medicationIntakeEvent.findMany({
where,
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
});
intakeCount = events.length;
sections.push("# Intake history");
sections.push(toCSV(formatIntakeEventsForExport(events)));
}
const body = sections.join("\n\n");
await auditLog("user.export.medications", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationCount: medications.length,
intakeCount,
includeIntake,
since: since?.toISOString() ?? null,
until: until?.toISOString() ?? null,
},
});
annotate({
meta: {
export_medications_count: medications.length,
export_intake_count: intakeCount,
export_include_intake: includeIntake,
},
});
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="healthlog-medications-${user.id}-${stamp}.csv"`,
"Cache-Control": "no-store",
},
});
});
function safeDate(value: string): Date | undefined {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* GET /api/export/mood
*
* v1.4.16 phase B7. Per-type CSV download for the consolidated
* `/settings/export` UI. Filterable by `since` / `until` (inclusive
* date strings parsed via the standard Date constructor — invalid
* values are silently dropped from the where-clause).
*
* Response is `text/csv` with an attachment filename ending in `.csv`.
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Rate-limit: shared `export:<userId>` bucket (10/h).
* Audit: `user.export.mood` with the resolved filter.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMoodEntriesForExport } from "@/lib/export";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.export.mood" } });
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);
}
const params = new URL(request.url).searchParams;
const since = params.get("since")
? safeDate(params.get("since")!)
: undefined;
const until = params.get("until")
? safeDate(params.get("until")!)
: undefined;
const where: {
userId: string;
moodLoggedAt?: { gte?: Date; lte?: Date };
} = {
userId: user.id,
};
if (since || until) {
where.moodLoggedAt = {};
if (since) where.moodLoggedAt.gte = since;
if (until) where.moodLoggedAt.lte = until;
}
const entries = await prisma.moodEntry.findMany({
where,
orderBy: { moodLoggedAt: "desc" },
});
const csv = toCSV(formatMoodEntriesForExport(entries));
await auditLog("user.export.mood", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
count: entries.length,
since: since?.toISOString() ?? null,
until: until?.toISOString() ?? null,
},
});
annotate({
meta: {
export_count: entries.length,
},
});
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(csv, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="healthlog-mood-${user.id}-${stamp}.csv"`,
"Cache-Control": "no-store",
},
});
});
function safeDate(value: string): Date | undefined {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}