mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
02c6658a35
commit
8b40066f42
5 files changed
+869
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<ZodOpenApiObject["components"]> = {
|
||||
// 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: {
|
||||
|
||||
@@ -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";
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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).",
|
||||
});
|
||||
Reference in new issue
Block a user