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

+140
View File
@@ -5202,6 +5202,54 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/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
WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,
BLOOD_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:
$ref: "#/components/schemas/CsvImportResponseEnvelope"
"401": *a1
"413":
description: CSV exceeds the 16 MB limit.
content:
application/json:
schema:
$ref: "#/components/schemas/CsvImportTooLargeEnvelope"
"422": *a3
"429": *a4
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -15570,6 +15618,98 @@ components:
- kind
additionalProperties: false
description: Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount.
CsvImportResponseEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/CsvImportResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CsvImportResponse:
type: object
properties:
inserted:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
updated:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
skipped:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
total:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Total data rows parsed (excludes header).
dryRun:
type: boolean
rows:
type: array
items:
$ref: "#/components/schemas/CsvImportRowResult"
required:
- inserted
- updated
- skipped
- total
- dryRun
- rows
additionalProperties: false
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.
CsvImportRowResult:
type: object
properties:
line:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: 1-based source line number (the header is line 1).
status:
type: string
enum:
- inserted
- updated
- skipped
reason:
description: Machine-readable reason for a non-inserted row (e.g. unknown_type, unknown_unit, missing_timezone_offset,
value_out_of_range, implausible_timestamp, duplicate).
type: string
required:
- line
- status
additionalProperties: false
CsvImportTooLargeEnvelope:
type: object
properties:
data:
type: "null"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MedicationScheduleOutput:
type: object
properties: