mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
82e2d7dfb1
commit
fbdcb31202
15 files changed
+1666
-2
No files matched your search
@@ -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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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"]> = {
|
||||
|
||||
Reference in new issue
Block a user