feat(import): add CSV measurement import with per-row status and dry-run

A self-hoster migrating from a spreadsheet or a meter export overwhelmingly
has CSV, which neither the JSON nor the Apple Health import covers. Add
`POST /api/import/csv` reusing the existing write loop and rollup re-fold.

The documented header is `type,value,unit,measuredAt[,glucoseContext,notes,
externalId]`, order-independent. measuredAt must carry an explicit ISO-8601
offset — a row without one is skipped rather than guessed — and is bounded by
the shared entry-instant validator. Glucose accepts mmol/L (converted to
canonical mg/dL) and weight accepts lb (converted to kg); any other
non-canonical unit is skipped, never mis-stored. An optional externalId
column makes a re-upload idempotent; without it a re-upload duplicates.

The response carries a per-row status envelope so a 2000-row file with three
bad rows imports 1997 and names the three. `?dryRun=1` validates and previews
the outcome without writing. The parse, validation, and unit conversion live
in a pure module so they are unit-tested without a database.

Settings -> Export & Import gains a third CSV card mirroring the JSON and
Apple Health cards, with upload, paste, preview, and a downloadable example.
The new route is documented in the OpenAPI contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:28:22 +02:00
1 parent 82e2d7dfb1
commit fbdcb31202
15 files changed
+1666 -2

No files matched your search

@@ -0,0 +1,204 @@
import { describe, it, expect } from "vitest";
import {
parseCsvMeasurements,
splitCsvRows,
} from "@/lib/import/csv-measurements";
// Pin the clock so the entry-instant bound is deterministic. All fixture
// timestamps sit comfortably in the past relative to this.
const NOW = new Date("2026-06-01T00:00:00.000Z").getTime();
const HEADER = "type,value,unit,measuredAt,glucoseContext,notes,externalId";
function parse(rows: string[]) {
return parseCsvMeasurements([HEADER, ...rows].join("\n"), { now: NOW });
}
describe("splitCsvRows", () => {
it("handles CRLF, quoted commas, escaped quotes, and a BOM", () => {
const csv =
"a,b,c\r\n1,\"x,y\",\"he said \"\"hi\"\"\"\r\n2,plain,z\r\n";
const grid = splitCsvRows(csv);
expect(grid).toEqual([
["a", "b", "c"],
["1", "x,y", 'he said "hi"'],
["2", "plain", "z"],
]);
});
it("drops trailing blank lines", () => {
expect(splitCsvRows("a,b\n1,2\n\n\n")).toEqual([
["a", "b"],
["1", "2"],
]);
});
});
describe("parseCsvMeasurements — header validation", () => {
it("is fatal when a required column is missing", () => {
const out = parseCsvMeasurements("type,value,unit\nWEIGHT,80,kg", {
now: NOW,
});
expect(out.fatal?.reason).toBe("missing_required_column");
expect(out.rows).toEqual([]);
});
it("is fatal on an empty file", () => {
const out = parseCsvMeasurements("", { now: NOW });
expect(out.fatal?.reason).toBe("missing_required_column");
});
it("accepts columns in any order", () => {
const out = parseCsvMeasurements(
["unit,measuredAt,type,value", "kg,2026-05-01T08:00:00Z,WEIGHT,80.5"].join(
"\n",
),
{ now: NOW },
);
expect(out.fatal).toBeUndefined();
expect(out.rows[0].status).toBe("ok");
expect(out.rows[0].row?.value).toBe(80.5);
});
});
describe("parseCsvMeasurements — per-row partial failure", () => {
it("imports the good rows and skips the bad ones with distinct reasons", () => {
const out = parse([
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,", // ok
"NOPE,1,kg,2026-05-01T08:00:00Z,,,", // unknown_type
"WEIGHT,9999,kg,2026-05-01T08:00:00Z,,,", // value_out_of_range
"WEIGHT,80,kg,2026-05-01T08:00:00,,,", // missing_timezone_offset
]);
const byLine = Object.fromEntries(
out.rows.map((r) => [r.line, { status: r.status, reason: r.reason }]),
);
expect(byLine[2]).toEqual({ status: "ok", reason: undefined });
expect(byLine[3]).toEqual({ status: "skipped", reason: "unknown_type" });
expect(byLine[4]).toEqual({
status: "skipped",
reason: "value_out_of_range",
});
expect(byLine[5]).toEqual({
status: "skipped",
reason: "missing_timezone_offset",
});
});
it("reports line numbers 1-based with the header as line 1", () => {
const out = parse(["WEIGHT,80,kg,2026-05-01T08:00:00Z,,,"]);
expect(out.rows[0].line).toBe(2);
});
});
describe("parseCsvMeasurements — unit conversion", () => {
it("converts glucose mmol/L to canonical mg/dL", () => {
const out = parse([
"BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:00:00Z,FASTING,,",
]);
expect(out.rows[0].status).toBe("ok");
expect(out.rows[0].row?.unit).toBe("mg/dL");
expect(out.rows[0].row?.value).toBeCloseTo(5.3 * 18.016, 3);
});
it("converts weight lb to canonical kg", () => {
const out = parse(["WEIGHT,180,lb,2026-05-01T08:00:00Z,,,"]);
expect(out.rows[0].status).toBe("ok");
expect(out.rows[0].row?.unit).toBe("kg");
expect(out.rows[0].row?.value).toBeCloseTo(180 * 0.453592, 3);
});
it("accepts the canonical unit case-insensitively", () => {
const out = parse(["WEIGHT,80,KG,2026-05-01T08:00:00Z,,,"]);
expect(out.rows[0].status).toBe("ok");
expect(out.rows[0].row?.unit).toBe("kg");
});
it("skips an unrecognised unit rather than mis-storing", () => {
const out = parse(["WEIGHT,80,stone,2026-05-01T08:00:00Z,,,"]);
expect(out.rows[0]).toMatchObject({
status: "skipped",
reason: "unknown_unit",
});
});
});
describe("parseCsvMeasurements — timezone + entry-instant bound", () => {
it("skips a timestamp without an offset (no silent local interpretation)", () => {
const out = parse(["WEIGHT,80,kg,2026-05-01T08:00:00,,,"]);
expect(out.rows[0].reason).toBe("missing_timezone_offset");
});
it("accepts both Z and ±HH:MM offsets", () => {
const out = parse([
"WEIGHT,80,kg,2026-05-01T08:00:00Z,,,",
"WEIGHT,81,kg,2026-05-01T08:00:00+02:00,,,",
]);
expect(out.rows.map((r) => r.status)).toEqual(["ok", "ok"]);
});
it("rejects a future-dated row via the entry-instant bound", () => {
// 10 minutes past NOW — beyond the 5-min skew tolerance.
const future = new Date(NOW + 10 * 60 * 1000).toISOString();
const out = parse([`WEIGHT,80,kg,${future},,,`]);
expect(out.rows[0]).toMatchObject({
status: "skipped",
reason: "implausible_timestamp",
});
});
it("rejects a pre-1900 row", () => {
const out = parse(["WEIGHT,80,kg,1899-12-31T00:00:00Z,,,"]);
expect(out.rows[0]).toMatchObject({
status: "skipped",
reason: "implausible_timestamp",
});
});
});
describe("parseCsvMeasurements — glucose context", () => {
it("requires a context for BLOOD_GLUCOSE", () => {
const out = parse(["BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,,,"]);
expect(out.rows[0]).toMatchObject({
status: "skipped",
reason: "missing_glucose_context",
});
});
it("rejects a context on a non-glucose row", () => {
const out = parse(["WEIGHT,80,kg,2026-05-01T08:00:00Z,FASTING,,"]);
expect(out.rows[0]).toMatchObject({
status: "skipped",
reason: "unexpected_glucose_context",
});
});
it("rejects an unknown context value", () => {
const out = parse(["BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,LUNCH,,"]);
expect(out.rows[0]).toMatchObject({
status: "skipped",
reason: "invalid_glucose_context",
});
});
});
describe("parseCsvMeasurements — optional columns", () => {
it("carries externalId through and trims notes", () => {
const out = parse(["WEIGHT,80,kg,2026-05-01T08:00:00Z,, after run ,ext-9"]);
expect(out.rows[0].row).toMatchObject({
externalId: "ext-9",
notes: "after run",
});
});
it("works without the optional columns present at all", () => {
const out = parseCsvMeasurements(
["type,value,unit,measuredAt", "WEIGHT,80,kg,2026-05-01T08:00:00Z"].join(
"\n",
),
{ now: NOW },
);
expect(out.rows[0].status).toBe("ok");
expect(out.rows[0].row?.externalId).toBeUndefined();
});
});
+368
View File
@@ -0,0 +1,368 @@
/**
* CSV measurement import — parse + normalise + per-row validation (v1.17.1).
*
* A cold-start escape hatch for self-hosters migrating from a spreadsheet,
* a glucose-meter export, or another tracker. The genuine import gap left
* by the JSON (`/api/import`) and Apple-Health (`/api/import/apple-health-export`)
* routes is plain CSV, which neither covers.
*
* This module is the pure, side-effect-free core: it turns raw CSV text
* into a per-row result set carrying either a normalised measurement (ready
* for the `/api/import` write loop) or a `skipped` status with a stable
* machine-readable `reason`. The route (`/api/import/csv`) owns the DB write
* + rollup re-fold; everything testable without a database lives here.
*
* Documented column schema (header row required, order-independent):
*
* type,value,unit,measuredAt[,glucoseContext][,notes][,externalId]
*
* - `type` — a `measurementTypeEnum` value (e.g. WEIGHT). Unknown → skipped.
* - `value` — a number in the row's `unit`.
* - `unit` — the canonical unit for the type, OR a recognised alias:
* glucose mmol/L → mg/dL (× 18.016),
* weight lb → kg (× 0.453592).
* Any other non-canonical unit → skipped (never silently
* mis-stored).
* - `measuredAt` — ISO-8601 WITH an explicit offset (`Z` or ±HH:MM). A row
* without an offset is skipped — the importer never guesses
* a timezone. The instant is bounded by `validateEntryInstant`
* (no future beyond a 5-min skew, no instant before 1900).
* - `glucoseContext` — required for BLOOD_GLUCOSE, forbidden otherwise.
* - `notes` — free text, ≤ 200 chars.
* - `externalId` — optional source-stable id. When present, the write loop
* upserts on `(userId, type, source=IMPORT, externalId)` so a
* re-upload is idempotent. Absent → re-upload duplicates.
*/
import {
measurementTypeEnum,
glucoseContextEnum,
getUnitForType,
validateMeasurementRange,
MEASUREMENT_NOTES_MAX_LENGTH,
} from "@/lib/validations/measurement";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
/** Canonical conversion factors keyed by `${type}:${loweredAlias}`. */
const GLUCOSE_MMOL_TO_MGDL = 18.016;
const LB_TO_KG = 0.453592;
/** A normalised, write-ready row in the `/api/import` measurement shape. */
export interface NormalisedMeasurementRow {
type: string;
value: number;
unit: string;
measuredAt: Date;
glucoseContext?: string;
notes?: string;
externalId?: string;
}
/** Stable machine-readable skip reasons, mirroring the batch route's vocabulary. */
export type CsvRowReason =
| "missing_required_column"
| "unknown_type"
| "invalid_value"
| "value_out_of_range"
| "unknown_unit"
| "missing_timezone_offset"
| "invalid_timestamp"
| "implausible_timestamp"
| "missing_glucose_context"
| "unexpected_glucose_context"
| "invalid_glucose_context"
| "notes_too_long"
| "external_id_too_long"
| "wrong_column_count";
export type CsvRowStatus = "ok" | "skipped";
export interface CsvRowResult {
/** 1-based source line number (header is line 1, first data row is line 2). */
line: number;
status: CsvRowStatus;
reason?: CsvRowReason;
/** Present only when `status === "ok"`. */
row?: NormalisedMeasurementRow;
}
export interface CsvParseOutcome {
/** A fatal, file-level error (e.g. missing required header). When set, `rows` is empty. */
fatal?: { reason: CsvRowReason; message: string };
rows: CsvRowResult[];
}
const REQUIRED_COLUMNS = ["type", "value", "unit", "measuredat"] as const;
const OPTIONAL_COLUMNS = ["glucosecontext", "notes", "externalid"] as const;
/**
* Split CSV text into rows of cells. Hand-rolled, zero-dependency, RFC-4180-ish:
* handles quoted fields, escaped quotes (`""`), commas + newlines inside quotes,
* a leading UTF-8 BOM, and CRLF / LF line endings. Trailing blank lines are
* dropped. Sufficient for the flat measurement schema; not a general CSV engine.
*/
export function splitCsvRows(text: string): string[][] {
// Strip a leading BOM so the first header cell isn't "type".
const input = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
const rows: string[][] = [];
let cell = "";
let row: string[] = [];
let inQuotes = false;
let sawAnyChar = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (inQuotes) {
if (ch === '"') {
if (input[i + 1] === '"') {
cell += '"';
i++;
} else {
inQuotes = false;
}
} else {
cell += ch;
}
continue;
}
if (ch === '"') {
inQuotes = true;
sawAnyChar = true;
continue;
}
if (ch === ",") {
row.push(cell);
cell = "";
sawAnyChar = true;
continue;
}
if (ch === "\r") {
// Swallow; the \n (if any) finalises the row.
continue;
}
if (ch === "\n") {
row.push(cell);
rows.push(row);
row = [];
cell = "";
sawAnyChar = false;
continue;
}
cell += ch;
sawAnyChar = true;
}
// Flush a trailing row that did not end in a newline.
if (sawAnyChar || cell.length > 0 || row.length > 0) {
row.push(cell);
rows.push(row);
}
// Drop fully-empty rows (e.g. a trailing blank line that produced [""]).
return rows.filter((r) => !(r.length === 1 && r[0].trim() === ""));
}
/** True when an ISO-8601 datetime string carries an explicit offset (`Z` or ±HH:MM). */
function hasExplicitOffset(value: string): boolean {
// Offset is in the time portion, after the `T`. Match a trailing Z or ±HH:MM.
return /([zZ]|[+-]\d{2}:?\d{2})$/.test(value.trim());
}
/**
* Resolve the row's unit to the canonical unit + converted value, or return
* `null` when the unit is neither canonical nor a recognised alias.
*/
function convertToCanonicalUnit(
type: string,
value: number,
rawUnit: string,
): { value: number; unit: string } | null {
const canonical = getUnitForType(type);
const unit = rawUnit.trim();
// Case-insensitive match against the canonical unit (mg/dL vs MG/DL).
if (unit.toLowerCase() === canonical.toLowerCase()) {
return { value, unit: canonical };
}
const lower = unit.toLowerCase();
// Glucose: mmol/L → mg/dL.
if (type === "BLOOD_GLUCOSE" && (lower === "mmol/l" || lower === "mmol")) {
return { value: value * GLUCOSE_MMOL_TO_MGDL, unit: canonical };
}
// Weight: lb → kg.
if (
type === "WEIGHT" &&
(lower === "lb" || lower === "lbs" || lower === "pound" || lower === "pounds")
) {
return { value: value * LB_TO_KG, unit: canonical };
}
return null;
}
const KNOWN_TYPES = new Set(measurementTypeEnum.options as readonly string[]);
const KNOWN_GLUCOSE_CONTEXTS = new Set(
glucoseContextEnum.options as readonly string[],
);
/**
* Parse + validate + normalise a CSV measurement file. Returns a per-row
* result set: each data row is either `ok` with a normalised measurement or
* `skipped` with a stable reason. A missing required header column is the
* only fatal, file-level failure.
*
* `now` is injectable so tests pin the entry-instant clock.
*/
export function parseCsvMeasurements(
text: string,
opts: { now?: number } = {},
): CsvParseOutcome {
const now = opts.now ?? Date.now();
const grid = splitCsvRows(text);
if (grid.length === 0) {
return {
fatal: {
reason: "missing_required_column",
message: "The CSV is empty — a header row is required.",
},
rows: [],
};
}
const header = grid[0].map((h) => h.trim().toLowerCase());
const colIndex = new Map<string, number>();
header.forEach((name, idx) => {
if (!colIndex.has(name)) colIndex.set(name, idx);
});
const missing = REQUIRED_COLUMNS.filter((c) => !colIndex.has(c));
if (missing.length > 0) {
return {
fatal: {
reason: "missing_required_column",
message: `Missing required column(s): ${missing.join(", ")}. Required header: ${REQUIRED_COLUMNS.join(",")}[,${OPTIONAL_COLUMNS.join(",")}].`,
},
rows: [],
};
}
const idxType = colIndex.get("type")!;
const idxValue = colIndex.get("value")!;
const idxUnit = colIndex.get("unit")!;
const idxMeasuredAt = colIndex.get("measuredat")!;
const idxContext = colIndex.get("glucosecontext");
const idxNotes = colIndex.get("notes");
const idxExternalId = colIndex.get("externalid");
const rows: CsvRowResult[] = [];
for (let r = 1; r < grid.length; r++) {
const cells = grid[r];
const line = r + 1; // 1-based, header is line 1.
const cellAt = (idx: number | undefined): string =>
idx === undefined ? "" : (cells[idx] ?? "").trim();
const type = cellAt(idxType);
const rawValue = cellAt(idxValue);
const rawUnit = cellAt(idxUnit);
const rawMeasuredAt = cellAt(idxMeasuredAt);
const rawContext = cellAt(idxContext);
const rawNotes = idxNotes === undefined ? "" : (cells[idxNotes] ?? "");
const rawExternalId = cellAt(idxExternalId);
const skip = (reason: CsvRowReason): void => {
rows.push({ line, status: "skipped", reason });
};
if (!KNOWN_TYPES.has(type)) {
skip("unknown_type");
continue;
}
const value = Number(rawValue);
if (rawValue === "" || !Number.isFinite(value)) {
skip("invalid_value");
continue;
}
const converted = convertToCanonicalUnit(type, value, rawUnit);
if (converted === null) {
skip("unknown_unit");
continue;
}
if (validateMeasurementRange(type, converted.value) !== null) {
skip("value_out_of_range");
continue;
}
if (!rawMeasuredAt) {
skip("invalid_timestamp");
continue;
}
if (!hasExplicitOffset(rawMeasuredAt)) {
skip("missing_timezone_offset");
continue;
}
const measuredAt = new Date(rawMeasuredAt);
if (Number.isNaN(measuredAt.getTime())) {
skip("invalid_timestamp");
continue;
}
if (!isPlausibleEntryInstant(measuredAt, { now })) {
skip("implausible_timestamp");
continue;
}
// Glucose context: required for BLOOD_GLUCOSE, forbidden otherwise.
let glucoseContext: string | undefined;
if (type === "BLOOD_GLUCOSE") {
if (!rawContext) {
skip("missing_glucose_context");
continue;
}
if (!KNOWN_GLUCOSE_CONTEXTS.has(rawContext)) {
skip("invalid_glucose_context");
continue;
}
glucoseContext = rawContext;
} else if (rawContext) {
skip("unexpected_glucose_context");
continue;
}
const notes = rawNotes.trim();
if (notes.length > MEASUREMENT_NOTES_MAX_LENGTH) {
skip("notes_too_long");
continue;
}
if (rawExternalId.length > 120) {
skip("external_id_too_long");
continue;
}
rows.push({
line,
status: "ok",
row: {
type,
value: converted.value,
unit: converted.unit,
measuredAt,
...(glucoseContext ? { glucoseContext } : {}),
...(notes ? { notes } : {}),
...(rawExternalId ? { externalId: rawExternalId } : {}),
},
});
}
return { rows };
}
/** Column order for the downloadable example + docs. */
export const CSV_EXAMPLE_COLUMNS = [
"type",
"value",
"unit",
"measuredAt",
"glucoseContext",
"notes",
"externalId",
] as const;
+97
View File
@@ -0,0 +1,97 @@
/**
* OpenAPI route table — CSV measurement import (v1.17.1).
*
* The CSV importer is the cold-start escape hatch for self-hosters migrating
* from a spreadsheet or another tracker. The JSON (`/api/import`) and Apple-
* Health (`/api/import/apple-health-export`) routes predate the contract
* surface and are intentionally not documented here; this module documents
* the one new endpoint so the wire contract stays drift-free (CI gate). iOS
* does not call it — its canonical ingest is `POST /api/measurements/batch`.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { dataEnvelope, stdResponses } from "./shared";
const csvImportRowResult = z
.object({
line: z
.number()
.int()
.describe("1-based source line number (the header is line 1)."),
status: z.enum(["inserted", "updated", "skipped"]),
reason: z
.string()
.optional()
.describe(
"Machine-readable reason for a non-inserted row (e.g. unknown_type, unknown_unit, missing_timezone_offset, value_out_of_range, implausible_timestamp, duplicate).",
),
})
.meta({ id: "CsvImportRowResult" });
const csvImportResponse = z
.object({
inserted: z.number().int(),
updated: z.number().int(),
skipped: z.number().int(),
total: z.number().int().describe("Total data rows parsed (excludes header)."),
dryRun: z.boolean(),
rows: z.array(csvImportRowResult),
})
.meta({
id: "CsvImportResponse",
description:
"Per-row import outcome. Mirrors the batch-route per-entry envelope. In `dryRun` mode no write runs and every valid row reports its projected `inserted` status.",
});
export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/import/csv": {
post: {
tags: ["Import"],
summary: "Import measurements from CSV",
description:
"Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.",
parameters: [
{
name: "dryRun",
in: "query",
required: false,
schema: { type: "string", enum: ["1", "true"] },
description:
"When `1` / `true`, parse + validate + return the per-row envelope without writing.",
},
],
requestBody: {
required: true,
content: {
"text/csv": {
schema: {
type: "string",
example:
"type,value,unit,measuredAt,glucoseContext,notes,externalId\nWEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,\nBLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001",
},
},
},
},
responses: {
"200": {
description: "Per-row import outcome.",
content: {
"application/json": {
schema: dataEnvelope(csvImportResponse, "CsvImportResponseEnvelope"),
},
},
},
...stdResponses,
"413": {
description: "CSV exceeds the 16 MB limit.",
content: {
"application/json": {
schema: dataEnvelope(z.null(), "CsvImportTooLargeEnvelope"),
},
},
},
},
},
},
};
+2
View File
@@ -28,6 +28,7 @@ import { consentPaths } from "./consent";
import { cyclePaths } from "./cycle";
import { devicePaths } from "./devices";
import { healthRecordPaths } from "./health-record";
import { importPaths } from "./import";
import { insightsPaths } from "./insights";
import { measurementPaths } from "./measurements";
import { medicationPaths, medicationResource } from "./medications";
@@ -57,6 +58,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...moodPaths,
...settingsPaths,
...consentPaths,
...importPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {