mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 23:12:04 +00:00
feat(import): import lab results and medications from CSV
Two streamed endpoints read a spreadsheet record by record with a column mapping from the query: separator, decimal mark, date order and zone are stated, never guessed per row. Lab rows resolve to markers the way scanned reports do, convert units where the conversion is known and keep what the file said; medication rows map name, dose, daily times, as-needed and a dated window, checked against the create schema, with reminders off unless asked for. A dry run writes nothing, a re-upload only finds duplicates, and every refused row comes back with its line and reason.
This commit is contained in:
1 parent
c94766efc3
commit
a84fb4b467
21 files changed
+3593
-3
No files matched your search
@@ -226,7 +226,14 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"illness.episode.parent-not-found",
|
||||
"illness.insights.invalid",
|
||||
],
|
||||
import: ["import.apple_health.busy", "import.write_failed"],
|
||||
import: [
|
||||
"import.apple_health.busy",
|
||||
"import.csv.content_type",
|
||||
"import.csv.empty",
|
||||
"import.csv.too_large",
|
||||
"import.rate_limited",
|
||||
"import.write_failed",
|
||||
],
|
||||
ingest: ["ingest.rate_limited"],
|
||||
insights: [
|
||||
"insights.generate.budgetExceeded",
|
||||
|
||||
@@ -23,6 +23,41 @@ import {
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
import {
|
||||
labsCsvQuerySchema,
|
||||
medicationsCsvQuerySchema,
|
||||
recordImportResultSchema,
|
||||
} from "@/lib/validations/record-import";
|
||||
|
||||
const RECORD_IMPORT_COMMON =
|
||||
"Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off.";
|
||||
|
||||
function recordImportResponses(id: string) {
|
||||
return {
|
||||
"200": {
|
||||
description: "Per-file and per-row outcome.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(recordImportResultSchema, id),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
"403": {
|
||||
description:
|
||||
"`module.disabled`: the module the import writes to is switched off.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "`import.csv.too_large`: the file is larger than 16 MB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"415": {
|
||||
description: "`import.csv.content_type`: the body is not `text/csv`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const csvImportRowResult = z
|
||||
.object({
|
||||
@@ -351,6 +386,48 @@ export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/import/labs-csv": {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
summary: "Import lab results from CSV",
|
||||
description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`,
|
||||
requestParams: { query: labsCsvQuerySchema },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"text/csv": {
|
||||
schema: {
|
||||
type: "string",
|
||||
example:
|
||||
"Analyte;Value;Unit;Date;Range\nHbA1c;5,4;%;01.03.2025;4,0-5,6\nUrine protein;negative;;01.03.2025;",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: recordImportResponses("LabsCsvImportEnvelope"),
|
||||
},
|
||||
},
|
||||
"/api/import/medications-csv": {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
summary: "Import medications from CSV",
|
||||
description: `${RECORD_IMPORT_COMMON} Maps name, dose (as written), daily intake times, an as-needed mark and a start and end date; every other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed. Each row is checked against the create route's own schema; a dated window opens a course. Reminders stay off unless \`reminders=1\`. A medication the record holds under the same name and dose is a duplicate. At most 500 data rows.`,
|
||||
requestParams: { query: medicationsCsvQuerySchema },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"text/csv": {
|
||||
schema: {
|
||||
type: "string",
|
||||
example:
|
||||
"name,dose,times,as needed,start,end\nRamipril,5 mg,08:00,,2025-01-01,\nIbuprofen,400 mg,,yes,,",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: recordImportResponses("MedicationsCsvImportEnvelope"),
|
||||
},
|
||||
},
|
||||
"/api/import": {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
|
||||
Reference in new issue
Block a user