From 8b40066f427a8bf618db2653ee87865d47ec96c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 20:02:00 +0200 Subject: [PATCH] docs(openapi): add dose-history import and its job-status poll Documents POST /api/medications/intake/dose-history-import and GET /api/medications/{id}/intake/import/{jobId}/status, including the full 16-value MedicationImportSkipReason set the v1.33.0 shape widened to, read straight from MEDICATION_IMPORT_SKIP_REASONS so the two never drift again. Also force-registers MedicationDoseHistoryImportFatalReason into components.schemas: the 422 description names it, but nothing else $refs it, and zod-openapi drops unreferenced schemas silently. --- docs/api/openapi.yaml | 482 ++++++++++++++++++ src/lib/openapi/routes/index.ts | 15 +- src/lib/openapi/routes/medications/index.ts | 5 +- src/lib/openapi/routes/medications/paths.ts | 103 ++++ src/lib/openapi/routes/medications/schemas.ts | 266 ++++++++++ 5 files changed, 869 insertions(+), 2 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c2a4e6153..efc7c4f50 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4473,6 +4473,123 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/medications/intake/dose-history-import: + post: + tags: + - Medications + summary: Import a dose history exported from another medication tracker + description: "Reads a ten-column CSV export covering a whole regimen (multiple medications in one file), matching each + row against the caller's existing medications by name — a medication is NEVER created from an import file. + `text/csv` carries the actual dose history (`Date`/`Scheduled Date` in separate columns, mapped onto + `takenAt`/`scheduledFor`). `application/json` is accepted at the content-type check but the documented JSON + export shape always refuses at 422 (`json_carries_no_intake_time`): it has no field for when a dose was actually + taken, and inferring one from the scheduled time would manufacture an on-time history the file never claimed. + Body capped at 16 MB and 30 000 importable rows. `?dryRun=1` parses, matches, and returns the file verdict + WITHOUT writing. A real submission queues the write onto the same background job the per-medication import uses + (chunked, heartbeated, resumable) and returns 202 with a `statusUrl` to poll. Shares the 5/hour `import:` rate + bucket with every other bulk import surface. Auth via cookie or Bearer." + parameters: + - name: dryRun + in: query + required: false + schema: + type: string + enum: + - "1" + - "true" + description: When `1` / `true`, parse + match + return the file verdict without queuing a write. + requestBody: + required: true + content: + text/csv: + schema: + type: string + application/json: + schema: + type: string + responses: + "200": + description: Dry-run verdict (nothing written). + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationDoseHistoryImportDryRunEnvelope" + "202": + description: Import queued. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" + "401": *a1 + "409": + description: A dose-history import is already in progress. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: File exceeds the 16 MB limit. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: Content-Type must be text/csv or application/json. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The file could not be read (see `MedicationDoseHistoryImportFatalReason` for the closed `errorCode` set), + or it holds more than 30 000 importable rows (`errorCode: too_many_rows`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a4 + "503": + description: Background worker is not available. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/medications/{id}/intake/import/{jobId}/status: + get: + tags: + - Medications + summary: Poll a per-medication intake-import job + description: Returns the queued/running/finished state of a per-medication intake-import job, scoped to the caller's own + medication. A job created by the account-wide dose-history import (which carries no `medicationId`) never + resolves through this path — poll its own `statusUrl` instead. v1.33.0 widened `result.skipReasons[].reason` / + `progress.skipDetails[].reason` to the full 16-value `MedicationImportSkipReason` set. A foreign or unknown + job/medication id is sealed as 404. Auth via cookie or Bearer. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: jobId + schema: + type: string + required: true + responses: + "200": + description: Current job status. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" + "401": *a1 + "404": + description: No such job for this medication (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/user/ai-provider: get: tags: @@ -22436,6 +22553,359 @@ components: - data - error additionalProperties: false + MedicationDoseHistoryImportDryRunEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationDoseHistoryImportResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationDoseHistoryImportResponse: + type: object + properties: + dryRun: + type: boolean + jobId: + anyOf: + - type: string + - type: "null" + description: Null on a dry run (nothing was queued). + status: + description: Present only on a real (non-dry-run) submission. + type: string + const: queued + statusUrl: + anyOf: + - type: string + - type: "null" + description: Relative poll path (`/api/medications/intake/dose-history-import/{jobId}/status`). Null on a dry run. + file: + $ref: "#/components/schemas/MedicationDoseHistoryImportFile" + required: + - dryRun + - jobId + - statusUrl + - file + additionalProperties: false + description: The file-level verdict, plus the queued job pointer on a real submission. A dry run (`?dryRun=1`) returns + the same `file` verdict with `jobId`/`statusUrl` null and no `status` field — parses, matches, and reports + without writing anything. + MedicationDoseHistoryImportFile: + type: object + properties: + rowsRead: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Data rows read from the file, refusals included. + queued: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Rows that parsed and matched a medication. + refused: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Total rows that did not queue, across every reason. + refusedByReason: + type: array + items: + $ref: "#/components/schemas/MedicationImportSkipGroup" + description: One entry per reason that actually occurred, count descending then reason. + unmatchedMedications: + maxItems: 50 + type: array + items: + type: string + description: Medication names the file used that match nothing on the record, deduplicated, first-seen order, capped at + 50. + ambiguousMedications: + maxItems: 50 + type: array + items: + type: string + description: Names that match more than one medication, so no single one can be meant. Capped at 50. + mirroredMedications: + maxItems: 50 + type: array + items: + type: string + description: Names that match only a medication mirrored from an external list. Capped at 50. + unknownColumns: + maxItems: 50 + type: array + items: + type: string + description: Header cells the importer has no verdict for. Capped at 50. + codingsNotRead: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Rows whose `Codings` cell held something left unread. + fromArchivedMedications: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Rows the file marked as belonging to a medication archived in the source. + required: + - rowsRead + - queued + - refused + - refusedByReason + - unmatchedMedications + - ambiguousMedications + - mirroredMedications + - unknownColumns + - codingsNotRead + - fromArchivedMedications + additionalProperties: false + MedicationImportSkipGroup: + type: object + properties: + reason: + $ref: "#/components/schemas/MedicationImportSkipReason" + count: + type: integer + minimum: 1 + maximum: 9007199254740991 + required: + - reason + - count + additionalProperties: false + MedicationImportSkipReason: + type: string + enum: + - duplicate_in_file + - already_recorded + - medication_not_found + - medication_ambiguous + - medication_is_mirrored + - status_no_dose_information + - status_reminder_event + - status_notification_not_sent + - status_unknown + - missing_timestamp + - missing_timezone_offset + - unreadable_timestamp + - implausible_timestamp + - missing_medication + - unreadable_dosage + - unreadable_row + description: "Why one source row did not become an intake row. Closed 16-value set: duplicate_in_file, already_recorded, + medication_not_found, medication_ambiguous, medication_is_mirrored, status_no_dose_information, + status_reminder_event, status_notification_not_sent, status_unknown, missing_timestamp, missing_timezone_offset, + unreadable_timestamp, implausible_timestamp, missing_medication, unreadable_dosage, unreadable_row." + MedicationDoseHistoryImportQueuedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationDoseHistoryImportResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationIntakeImportJobStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationIntakeImportJobStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationIntakeImportJobStatusResponse: + type: object + properties: + jobId: + type: string + status: + type: string + enum: + - queued + - running + - done + - failed + description: queued — admitted, not yet picked up. running — the worker is walking entries. done — finished (see + `result` for the outcome, even a fully-refused run). failed — the worker could not complete the run (see + `failureReason`). + progress: + $ref: "#/components/schemas/MedicationImportProgress" + result: + anyOf: + - $ref: "#/components/schemas/MedicationImportResult" + - type: "null" + description: Set only once `status` is `done`; null before that. + failureReason: + anyOf: + - type: string + - type: "null" + description: Set only when `status` is `failed`; null otherwise. + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + startedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: When the worker picked the job up; null while still queued. + completedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: When the job reached `done` or `failed`; null while active. + required: + - jobId + - status + - progress + - result + - failureReason + - createdAt + - startedAt + - completedAt + additionalProperties: false + description: Poll target for a queued intake-import job — shared by the per-medication import and the account-wide + dose-history import, so a client polls both through one shape. + MedicationImportProgress: + type: object + properties: + processed: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Entries the worker has walked so far, in file order. + total: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Entries queued for this job. + imported: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Entries that have reached the database so far. + skippedByReason: + type: object + propertyNames: + $ref: "#/components/schemas/MedicationImportSkipReason" + additionalProperties: + type: integer + minimum: 1 + maximum: 9007199254740991 + description: Running skip tally by reason. A reason with no skips yet is absent from the object, never present as 0. + skipDetails: + description: Same bounded detail list as the finished `result`. + type: array + items: + $ref: "#/components/schemas/MedicationImportSkipDetail" + skippedDetailsOmitted: + type: integer + minimum: 0 + maximum: 9007199254740991 + touchedDays: + type: array + items: + type: string + description: Local calendar days (YYYY-MM-DD) touched so far, driving the chunked rollup recompute behind the write. + rollupProcessed: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Of `touchedDays`, how many have had their rollup recomputed. + required: + - processed + - total + - imported + - skippedByReason + - touchedDays + - rollupProcessed + additionalProperties: false + description: "In-flight job progress, written whole at admission and advanced in place by the worker. Present from the + moment the job is created (queued), so `processed: 0, total: N` is the shape a client sees immediately after + kickoff, not an empty object." + MedicationImportSkipDetail: + type: object + properties: + line: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: 1-based source-file row ordinal. + reason: + $ref: "#/components/schemas/MedicationImportSkipReason" + required: + - line + - reason + additionalProperties: false + description: One refused source row, reduced to the ordinal + reason — never the row's own content (no medication name, + timestamp, or dosage text). + MedicationImportResult: + type: object + properties: + imported: + type: integer + minimum: 0 + maximum: 9007199254740991 + skipped: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Total entries that did not become a row. + skipReasons: + type: array + items: + $ref: "#/components/schemas/MedicationImportSkipGroup" + description: One entry per reason that actually occurred, count descending then reason — never the same sentence + repeated per row. Empty when nothing was skipped. + skipDetails: + description: First bounded source-row details (capped at 200). Absent on a legacy job run before this field existed. + type: array + items: + $ref: "#/components/schemas/MedicationImportSkipDetail" + skippedDetailsOmitted: + description: Count of additional skipped rows beyond the `skipDetails` cap, counted without retaining their contents. + Absent when nothing was omitted. + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - imported + - skipped + - skipReasons + additionalProperties: false + description: The finished job's outcome. Null while the job is still queued or running — `status` + `progress` are the + fields to read meanwhile. GetAiProviderResponse: type: object properties: @@ -35124,6 +35594,18 @@ components: description: Server-shaped medication row returned by GET / POST / PUT endpoints. Carries the v1.5 course-window fields (`startsOn`, `endsOn`, `oneShot`) at the medication level and the per-schedule cadence fields on the nested `schedules` array. + MedicationDoseHistoryImportFatalReason: + type: string + enum: + - empty_file + - missing_required_columns + - unreadable_json + - json_not_an_array + - json_carries_no_intake_time + description: Whole-file refusal reason on the 422 `errorCode`. empty_file — no data rows. missing_required_columns — the + CSV header is missing a column the parser needs. unreadable_json — the body did not parse as JSON. + json_not_an_array — parsed JSON is not an array (and no nested array was found under any key). + json_carries_no_intake_time — well-formed JSON export input, refused by design (see above). securitySchemes: bearerAuth: type: http diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index e364e0586..b048aa481 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -52,7 +52,11 @@ import { ocrPaths } from "./ocr"; import { measurementPaths } from "./measurements"; import { measurementReminderPaths } from "./measurement-reminders"; import { mentalHealthPaths } from "./mental-health"; -import { medicationPaths, medicationResource } from "./medications"; +import { + medicationPaths, + medicationResource, + medicationDoseHistoryImportFatalReasonEnum, +} from "./medications"; import { metaPaths } from "./meta"; import { moodPaths } from "./mood"; import { nutrientPaths } from "./nutrients"; @@ -121,8 +125,17 @@ export const openApiComponents: NonNullable = { // registration the standalone `Medication` component would never // emit. The iOS codegen reads from `Medication` directly for the // shared Swift struct backing both variants. + // + // `MedicationDoseHistoryImportFatalReason` lives here for the same reason: + // the dose-history-import 422 description names it as the closed set + // behind `errorCode`, but `ErrorEnvelope.meta.errorCode` is a bare string + // shared by every route, so nothing ever `$ref`s the enum itself. Without + // this forced registration the schema the description points to would be + // silently absent from the published spec. schemas: { Medication: medicationResource, + MedicationDoseHistoryImportFatalReason: + medicationDoseHistoryImportFatalReasonEnum, }, securitySchemes: { bearerAuth: { diff --git a/src/lib/openapi/routes/medications/index.ts b/src/lib/openapi/routes/medications/index.ts index 5149e6dd5..a716e225a 100644 --- a/src/lib/openapi/routes/medications/index.ts +++ b/src/lib/openapi/routes/medications/index.ts @@ -3,5 +3,8 @@ * * Part of the OpenAPI route table; aggregated in `../index.ts`. */ -export { medicationResource } from "./schemas"; +export { + medicationResource, + medicationDoseHistoryImportFatalReasonEnum, +} from "./schemas"; export { medicationPaths } from "./paths"; diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 1dd373726..fd1d40b23 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -56,6 +56,8 @@ import { medicationListLayoutResult, doseHistoryQuery, doseHistoryResponse, + medicationIntakeImportJobStatusResponse, + medicationDoseHistoryImportResponse, } from "./schemas"; // ── Bulk intake backfill (iOS SyncMode) — mirrors the route's @@ -922,4 +924,105 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/intake/dose-history-import": { + post: { + tags: ["Medications"], + summary: "Import a dose history exported from another medication tracker", + description: + "Reads a ten-column CSV export covering a whole regimen (multiple medications in one file), matching each row against the caller's existing medications by name — a medication is NEVER created from an import file. `text/csv` carries the actual dose history (`Date`/`Scheduled Date` in separate columns, mapped onto `takenAt`/`scheduledFor`). `application/json` is accepted at the content-type check but the documented JSON export shape always refuses at 422 (`json_carries_no_intake_time`): it has no field for when a dose was actually taken, and inferring one from the scheduled time would manufacture an on-time history the file never claimed. Body capped at 16 MB and 30 000 importable rows. `?dryRun=1` parses, matches, and returns the file verdict WITHOUT writing. A real submission queues the write onto the same background job the per-medication import uses (chunked, heartbeated, resumable) and returns 202 with a `statusUrl` to poll. Shares the 5/hour `import:` rate bucket with every other bulk import surface. Auth via cookie or Bearer.", + parameters: [ + { + name: "dryRun", + in: "query", + required: false, + schema: { type: "string", enum: ["1", "true"] }, + description: + "When `1` / `true`, parse + match + return the file verdict without queuing a write.", + }, + ], + requestBody: { + required: true, + content: { + "text/csv": { schema: { type: "string" } }, + "application/json": { schema: { type: "string" } }, + }, + }, + responses: { + "200": { + description: "Dry-run verdict (nothing written).", + content: { + "application/json": { + schema: dataEnvelope( + medicationDoseHistoryImportResponse, + "MedicationDoseHistoryImportDryRunEnvelope", + ), + }, + }, + }, + "202": { + description: "Import queued.", + content: { + "application/json": { + schema: dataEnvelope( + medicationDoseHistoryImportResponse, + "MedicationDoseHistoryImportQueuedEnvelope", + ), + }, + }, + }, + "409": { + description: "A dose-history import is already in progress.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "File exceeds the 16 MB limit.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "Content-Type must be text/csv or application/json.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "503": { + description: "Background worker is not available.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "The file could not be read (see `MedicationDoseHistoryImportFatalReason` for the closed `errorCode` set), or it holds more than 30 000 importable rows (`errorCode: too_many_rows`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/medications/{id}/intake/import/{jobId}/status": { + get: { + tags: ["Medications"], + summary: "Poll a per-medication intake-import job", + description: + "Returns the queued/running/finished state of a per-medication intake-import job, scoped to the caller's own medication. A job created by the account-wide dose-history import (which carries no `medicationId`) never resolves through this path — poll its own `statusUrl` instead. v1.33.0 widened `result.skipReasons[].reason` / `progress.skipDetails[].reason` to the full 16-value `MedicationImportSkipReason` set. A foreign or unknown job/medication id is sealed as 404. Auth via cookie or Bearer.", + requestParams: { + path: z.object({ id: z.string(), jobId: z.string() }), + }, + responses: { + "200": { + description: "Current job status.", + content: { + "application/json": { + schema: dataEnvelope( + medicationIntakeImportJobStatusResponse, + "MedicationIntakeImportJobStatusEnvelope", + ), + }, + }, + }, + "404": { + description: + "No such job for this medication (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 1e3425278..26dbf0f87 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -18,6 +18,7 @@ import { MEDICATION_ORDER_ID_MAX_LENGTH, MEDICATION_ORDER_MAX_ENTRIES, } from "@/lib/medication-list-layout"; +import { MEDICATION_IMPORT_SKIP_REASONS } from "@/lib/jobs/medication-intake-import"; // ── Medications (v1.5 scheduling) ──────────────────────────────────── // @@ -929,3 +930,268 @@ export const medicationListLayoutResult = medicationListLayoutSchema description: "Resolved medications list presentation plus the optimistic-concurrency `updatedAt` token.", }); + +// ── Intake-import jobs (v1.33.0) — the per-medication `/api/medications/{id}/ +// intake/import` route and the account-wide `/api/medications/intake/ +// dose-history-import` route both queue onto `MedicationIntakeImportJob` and +// are polled through the SAME status projection, so the shapes below are +// shared rather than duplicated per route. + +// The 16-reason closed set a row can be refused for, exactly as declared in +// `MEDICATION_IMPORT_SKIP_REASONS`. `duplicate_in_file` / `already_recorded` +// are dedup outcomes; `medication_not_found` / `medication_ambiguous` / +// `medication_is_mirrored` / `missing_medication` are medication-matching +// outcomes; `status_*` are source rows whose own status column said the dose +// was never taken (a reminder, an unsent notification, an unrecognised +// status); `missing_timestamp` / `missing_timezone_offset` / +// `unreadable_timestamp` / `implausible_timestamp` are timestamp problems; +// `unreadable_dosage` / `unreadable_row` are malformed cells. +export const medicationImportSkipReasonEnum = z + .enum(MEDICATION_IMPORT_SKIP_REASONS) + .meta({ + id: "MedicationImportSkipReason", + description: + "Why one source row did not become an intake row. Closed 16-value set: duplicate_in_file, already_recorded, medication_not_found, medication_ambiguous, medication_is_mirrored, status_no_dose_information, status_reminder_event, status_notification_not_sent, status_unknown, missing_timestamp, missing_timezone_offset, unreadable_timestamp, implausible_timestamp, missing_medication, unreadable_dosage, unreadable_row.", + }); + +const medicationImportSkipGroup = z + .object({ + reason: medicationImportSkipReasonEnum, + count: z.number().int().min(1), + }) + .meta({ id: "MedicationImportSkipGroup" }); + +const medicationImportSkipDetail = z + .object({ + line: z.number().int().describe("1-based source-file row ordinal."), + reason: medicationImportSkipReasonEnum, + }) + .meta({ + id: "MedicationImportSkipDetail", + description: + "One refused source row, reduced to the ordinal + reason — never the row's own content (no medication name, timestamp, or dosage text).", + }); + +// The terminal, persisted result. Reconstructed through a strict allowlist +// (`projectMedicationImportResult`) before it ever reaches a polling +// response, so a compromised or legacy JSON blob cannot smuggle anything +// past this shape. +export const medicationImportResult = z + .object({ + imported: z.number().int().min(0), + skipped: z + .number() + .int() + .min(0) + .describe("Total entries that did not become a row."), + skipReasons: z + .array(medicationImportSkipGroup) + .describe( + "One entry per reason that actually occurred, count descending then reason — never the same sentence repeated per row. Empty when nothing was skipped.", + ), + skipDetails: z + .array(medicationImportSkipDetail) + .optional() + .describe( + "First bounded source-row details (capped at 200). Absent on a legacy job run before this field existed.", + ), + skippedDetailsOmitted: z + .number() + .int() + .min(0) + .optional() + .describe( + "Count of additional skipped rows beyond the `skipDetails` cap, counted without retaining their contents. Absent when nothing was omitted.", + ), + }) + .meta({ + id: "MedicationImportResult", + description: + "The finished job's outcome. Null while the job is still queued or running — `status` + `progress` are the fields to read meanwhile.", + }); + +// The in-flight progress blob a running/queued job carries. Written whole at +// admission time and advanced by the worker in place; unlike `result` it is +// NOT re-validated on read (stored as a bare JSON column), so this documents +// the shape the job worker actually writes rather than a server-enforced +// contract. +export const medicationImportProgress = z + .object({ + processed: z + .number() + .int() + .min(0) + .describe("Entries the worker has walked so far, in file order."), + total: z.number().int().min(0).describe("Entries queued for this job."), + imported: z + .number() + .int() + .min(0) + .describe("Entries that have reached the database so far."), + skippedByReason: z + .partialRecord(medicationImportSkipReasonEnum, z.number().int().min(1)) + .describe( + "Running skip tally by reason. A reason with no skips yet is absent from the object, never present as 0.", + ), + skipDetails: z + .array(medicationImportSkipDetail) + .optional() + .describe("Same bounded detail list as the finished `result`."), + skippedDetailsOmitted: z.number().int().min(0).optional(), + touchedDays: z + .array(z.string()) + .describe( + "Local calendar days (YYYY-MM-DD) touched so far, driving the chunked rollup recompute behind the write.", + ), + rollupProcessed: z + .number() + .int() + .min(0) + .describe("Of `touchedDays`, how many have had their rollup recomputed."), + }) + .meta({ + id: "MedicationImportProgress", + description: + "In-flight job progress, written whole at admission and advanced in place by the worker. Present from the moment the job is created (queued), so `processed: 0, total: N` is the shape a client sees immediately after kickoff, not an empty object.", + }); + +export const medicationIntakeImportJobStatusResponse = z + .object({ + jobId: z.string(), + status: z + .enum(["queued", "running", "done", "failed"]) + .describe( + "queued — admitted, not yet picked up. running — the worker is walking entries. done — finished (see `result` for the outcome, even a fully-refused run). failed — the worker could not complete the run (see `failureReason`).", + ), + progress: medicationImportProgress, + result: medicationImportResult + .nullable() + .describe("Set only once `status` is `done`; null before that."), + failureReason: z + .string() + .nullable() + .describe("Set only when `status` is `failed`; null otherwise."), + createdAt: z.iso.datetime({ offset: true }), + startedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the worker picked the job up; null while still queued."), + completedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the job reached `done` or `failed`; null while active."), + }) + .meta({ + id: "MedicationIntakeImportJobStatusResponse", + description: + "Poll target for a queued intake-import job — shared by the per-medication import and the account-wide dose-history import, so a client polls both through one shape.", + }); + +// ── Account-wide dose-history import (v1.33.0) ─────────────────────── + +// The file-level verdict, identical for a dry run and a real submission. +const medicationDoseHistoryImportFile = z + .object({ + rowsRead: z + .number() + .int() + .min(0) + .describe("Data rows read from the file, refusals included."), + queued: z + .number() + .int() + .min(0) + .describe("Rows that parsed and matched a medication."), + refused: z + .number() + .int() + .min(0) + .describe("Total rows that did not queue, across every reason."), + refusedByReason: z + .array(medicationImportSkipGroup) + .describe( + "One entry per reason that actually occurred, count descending then reason.", + ), + unmatchedMedications: z + .array(z.string()) + .max(50) + .describe( + "Medication names the file used that match nothing on the record, deduplicated, first-seen order, capped at 50.", + ), + ambiguousMedications: z + .array(z.string()) + .max(50) + .describe( + "Names that match more than one medication, so no single one can be meant. Capped at 50.", + ), + mirroredMedications: z + .array(z.string()) + .max(50) + .describe( + "Names that match only a medication mirrored from an external list. Capped at 50.", + ), + unknownColumns: z + .array(z.string()) + .max(50) + .describe("Header cells the importer has no verdict for. Capped at 50."), + codingsNotRead: z + .number() + .int() + .min(0) + .describe("Rows whose `Codings` cell held something left unread."), + fromArchivedMedications: z + .number() + .int() + .min(0) + .describe( + "Rows the file marked as belonging to a medication archived in the source.", + ), + }) + .meta({ id: "MedicationDoseHistoryImportFile" }); + +export const medicationDoseHistoryImportResponse = z + .object({ + dryRun: z.boolean(), + jobId: z + .string() + .nullable() + .describe("Null on a dry run (nothing was queued)."), + status: z + .literal("queued") + .optional() + .describe("Present only on a real (non-dry-run) submission."), + statusUrl: z + .string() + .nullable() + .describe( + "Relative poll path (`/api/medications/intake/dose-history-import/{jobId}/status`). Null on a dry run.", + ), + file: medicationDoseHistoryImportFile, + }) + .meta({ + id: "MedicationDoseHistoryImportResponse", + description: + "The file-level verdict, plus the queued job pointer on a real submission. A dry run (`?dryRun=1`) returns the same `file` verdict with `jobId`/`statusUrl` null and no `status` field — parses, matches, and reports without writing anything.", + }); + +// The 5 file-level (whole-submission) refusal reasons — distinct from the +// 16 per-row `medicationImportSkipReasonEnum` above. `application/json` is +// accepted at the content-type check, but the documented JSON export shape +// always ends in `json_carries_no_intake_time`: it carries `scheduledDate` + +// `status` + `dosage` but no field for when a dose was actually taken, and +// writing the scheduled time as `takenAt` would manufacture an on-time +// history the file never claimed. `text/csv` is the only shape that +// actually imports. +export const medicationDoseHistoryImportFatalReasonEnum = z + .enum([ + "empty_file", + "missing_required_columns", + "unreadable_json", + "json_not_an_array", + "json_carries_no_intake_time", + ]) + .meta({ + id: "MedicationDoseHistoryImportFatalReason", + description: + "Whole-file refusal reason on the 422 `errorCode`. empty_file — no data rows. missing_required_columns — the CSV header is missing a column the parser needs. unreadable_json — the body did not parse as JSON. json_not_an_array — parsed JSON is not an array (and no nested array was found under any key). json_carries_no_intake_time — well-formed JSON export input, refused by design (see above).", + });