feat(medications): read a dose-history export directly

The intake importer took a hand-written list of dates and times for one
medication. A self-hoster with a ten-column export covering sixteen
medications and years of history was left translating it by hand, which is
where the scheduled time and the time a dose was actually taken get
flattened into one — the distinction the record wants most.

The export keeps them as two columns, so read the file's own shape.
`MedicationIntakeEvent` already models that pair as `scheduledFor` +
`takenAt`; an imported dose now populates both instead of anchoring itself
on the take. `Skipped` becomes a deliberate skip with no take time, which is
how every other skip path writes it. A dose whose actual amount differs from
the amount the schedule expected carries the difference as a per-dose
override; an unremarkable amount stays absent, which is what absent means
there.

Timestamps carry their own UTC offset (`+1030` and `+0930` on adjacent rows
of one file) and it is read literally: the epoch is computed from the stated
offset rather than handed to the platform's lenient date parser, and a value
without an offset is refused instead of assumed to be the server's zone.

An export covers a whole regimen, so the job is account-wide and each
payload entry names its own medication — migration 0276 widens
`medication_intake_import_jobs.medication_id` to nullable for it. The
per-medication route is unchanged and keeps naming its medication on the job
row. A medication is never created from an import file; unmatched names come
back verbatim so they can be added properly and the file re-run.

Every column gets a verdict, and `?dryRun=1` returns the whole one before
anything is written.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 16:37:10 +02:00
1 parent f0c5b5ae94
commit 11e6402b5a
13 files changed
+1748 -192

No files matched your search

@@ -0,0 +1,13 @@
-- An intake import can now cover a whole regimen.
--
-- The per-medication import route scopes its job to one medication and the
-- column named it. A dose history exported from another tracker is one file for
-- every medication a person takes, so that job belongs to the account and each
-- payload entry names its own medication instead. NULL means "account-wide",
-- not "unknown"; the worker refuses a payload entry that names no medication
-- and no job row to fall back on.
--
-- Widening only: every existing row keeps its medication and the per-medication
-- route keeps writing one.
ALTER TABLE "medication_intake_import_jobs"
ALTER COLUMN "medication_id" DROP NOT NULL;
+146 -140
View File
@@ -640,104 +640,104 @@ model User {
passkeyUpgradeNudgeDismissed Boolean @default(false) @map("passkey_upgrade_nudge_dismissed")
// Relations
passkeys Passkey[]
sessions Session[]
challenges AuthChallenge[]
passkeys Passkey[]
sessions Session[]
challenges AuthChallenge[]
// v1.23 — second-factor stores (kept separate from `passkeys`, which is the
// passwordless-primary credential set; an MFA credential is meaningless
// without the password).
mfaChallenges MfaChallenge[]
mfaRecoveryCodes MfaRecoveryCode[]
webauthnMfaCredentials WebauthnMfaCredential[]
knownDevices UserKnownDevice[]
mfaChallenges MfaChallenge[]
mfaRecoveryCodes MfaRecoveryCode[]
webauthnMfaCredentials WebauthnMfaCredential[]
knownDevices UserKnownDevice[]
// v1.23 — "remember this device" trusted-device tokens. A trusted device
// skips the second factor (factor 2 only) within the window; the password
// is still required. Revoked when the second factor is removed.
trustedDevices TrustedDevice[]
trustedDevices TrustedDevice[]
// v1.30.34 — short-lived, token-bound step-up elevations. The Bearer-transport
// equivalent of `Session.mfaVerifiedAt`: a token client re-proves a factor and
// receives one single-use elevation good for one MFA-management call.
stepUpElevations StepUpElevation[]
measurements Measurement[]
medications Medication[]
intakeEvents MedicationIntakeEvent[]
medicationPauseEras MedicationPauseEra[]
apiTokens ApiToken[]
stepUpElevations StepUpElevation[]
measurements Measurement[]
medications Medication[]
intakeEvents MedicationIntakeEvent[]
medicationPauseEras MedicationPauseEra[]
apiTokens ApiToken[]
// v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted
// authorization for an MCP client, audience-bound to health:read/health:write.
mcpOAuthConnections McpOAuthConnection[]
refreshTokens RefreshToken[]
mcpOAuthConnections McpOAuthConnection[]
refreshTokens RefreshToken[]
// v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the
// browser-context OIDC callback to the cookie-less native token exchange.
oidcNativeHandoffs OidcNativeHandoff[]
auditLogs AuditLog[]
withingsConnection WithingsConnection?
oidcNativeHandoffs OidcNativeHandoff[]
auditLogs AuditLog[]
withingsConnection WithingsConnection?
// v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the
// Withings OAuth state check. The connect route mints a row with a
// 10-minute TTL; the callback resolves the user via this relation
// instead of parsing the cookie value, closing the L-1 audit gap
// where the legacy `${user.id}:${nonce}` shape leaked the user id
// into request logs / network captures.
withingsOAuthStates WithingsOAuthState[]
withingsOAuthStates WithingsOAuthState[]
// v1.11.0 — WHOOP integration. Mirrors the Withings connection (1:1) +
// OAuth-state ledger (1:N) relations.
whoopConnection WhoopConnection?
whoopOAuthStates WhoopOAuthState[]
whoopConnectTickets WhoopConnectTicket[]
whoopConnection WhoopConnection?
whoopOAuthStates WhoopOAuthState[]
whoopConnectTickets WhoopConnectTicket[]
// v1.12.0 — Fitbit/Pixel integration. Mirrors the WHOOP connection (1:1) +
// OAuth-state ledger (1:N) relations.
fitbitConnection FitbitConnection?
fitbitOAuthStates FitbitOAuthState[]
fitbitConnection FitbitConnection?
fitbitOAuthStates FitbitOAuthState[]
// v1.27.0 — Google Health API integration (separate provider from the classic
// Fitbit Web API above). Mirrors the connection (1:1) + OAuth-state ledger
// (1:N) relations.
googleHealthConnection GoogleHealthConnection?
googleHealthOAuthStates GoogleHealthOAuthState[]
moodEntries MoodEntry[]
mentalHealthAssessments MentalHealthAssessment[]
moodTagsCustom MoodTag[] @relation("UserMoodTags")
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
moodTagsHidden MoodTagHidden[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
dataBackups DataBackup[]
achievements UserAchievement[]
recommendationFeedback RecommendationFeedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachPlans CoachPlan[]
coachReminders CoachReminder[]
coachUsage CoachUsage[]
googleHealthConnection GoogleHealthConnection?
googleHealthOAuthStates GoogleHealthOAuthState[]
moodEntries MoodEntry[]
mentalHealthAssessments MentalHealthAssessment[]
moodTagsCustom MoodTag[] @relation("UserMoodTags")
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
moodTagsHidden MoodTagHidden[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
dataBackups DataBackup[]
achievements UserAchievement[]
recommendationFeedback RecommendationFeedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachPlans CoachPlan[]
coachReminders CoachReminder[]
coachUsage CoachUsage[]
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
// session can build against a stable DTO. Routes hang off Workout
// (1:1), not User — the User → WorkoutRoute path is two hops by
// design.
workouts Workout[]
workouts Workout[]
// v1.4.25 W8d — personal records (schema only; detection worker
// lands in a later release). The GET /api/personal-records route
// ships with v1.4.25 so the iOS app can build its query path
// against a stable contract.
personalRecords PersonalRecord[]
personalRecords PersonalRecord[]
// Today rail dismiss ledger — observational PriorityItem kinds only.
dismissedPriorityItems DismissedPriorityItem[]
dismissedPriorityItems DismissedPriorityItem[]
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
// companion to the running-sum MedicationInventoryEvent ledger.
// Carries the EMA §6.3 30-day in-use clock per opened pen.
inventoryItems MedicationInventoryItem[]
inventoryItems MedicationInventoryItem[]
// v1.4.25 W19d — GLP-1 side-effect logbook. 21-entry × 5-category
// EMA EPAR §4.8-derived taxonomy with 1-5 Likert severity. Daily
// capture (not per-shot) so delayed-onset symptoms map to their
// actual occurrence time.
medicationSideEffects MedicationSideEffect[]
medicationSideEffects MedicationSideEffect[]
// v1.4.34 — Apple Health `export.zip` import jobs. Mirrors the
// pg-boss job state so the polling endpoint can read live per-
// phase progress without round-tripping through pg-boss internals.
importJobs ImportJob[]
medicationIntakeImportJobs MedicationIntakeImportJob[]
importJobs ImportJob[]
medicationIntakeImportJobs MedicationIntakeImportJob[]
// v1.5.0 — persistent measurement rollup table. Carries the
// count/min/max/mean/sd/slope/r2 stats per (type, granularity,
// bucketStart) so the analytics + comprehensive read paths bypass
@@ -745,112 +745,112 @@ model User {
// multi-instance fan-out. Populated synchronously on the DAY
// granularity from the measurement write hooks; WEEK/MONTH/YEAR
// are enqueued through pg-boss for the worker to fold.
measurementRollups MeasurementRollup[]
measurementRollups MeasurementRollup[]
// v0.5.4 ios-coord — per-day dedup ledger for MOOD_REMINDER pushes.
// One row per (user, local date) the worker has nudged. Prevents
// a second push when the cron rechecks the same hour later in the
// 22:00 window (the cron fires every 15 min — without the ledger
// a user could see three pushes back-to-back).
moodReminderDispatches MoodReminderDispatch[]
moodReminderDispatches MoodReminderDispatch[]
// v1.4.39 W-MOOD — per-(user, granularity, bucket) mood rollup. New
// tier behind the mood-analytics read path; per-write hooks fold
// MoodEntry mutations into the DAY granularity inline so the next
// read serves from this table instead of the unbounded
// MoodEntry.findMany walk.
moodEntryRollups MoodEntryRollup[]
moodEntryRollups MoodEntryRollup[]
// v1.4.39 W-MED — per-(user, medication, day) compliance rollup.
// Per-write hook on MedicationIntakeEvent mutations + the scheduler
// keep the row in sync so `/api/medications/intake?scope=compliance`
// and `computeUserHealthScoreFastPath` skip the per-event findMany.
medicationComplianceRollups MedicationComplianceRollup[]
medicationComplianceRollups MedicationComplianceRollup[]
// v1.4.40 SB-10 — AI consent receipts the iOS app POSTs when the user
// grants AI processing during onboarding. Persisted audit trail
// covers App-Store Guideline 5.1.2(i) ("data-collection consent must
// be verifiable") plus the operator's GDPR Art. 7 documentation
// burden. Append-only — a user revoking + re-granting consent mints
// a fresh row so the timeline reads chronologically.
consentReceipts ConsentReceipt[]
consentReceipts ConsentReceipt[]
// v1.4.49 — per-attempt push-delivery ledger. Every sender (APNS,
// WEB_PUSH, TELEGRAM, NTFY) writes one fire-and-forget row per
// dispatch so the admin diagnostic endpoint can return the trailing
// 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin
// retires rows older than 90 days (see `handlePushAttemptCleanup` in
// `reminder-worker.ts`).
pushAttempts PushAttempt[]
pushAttempts PushAttempt[]
// v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total
// TRIMP + running EWMA reference) so the nightly Strain engine anchors
// the 0–100 map to the user's own training load without re-integrating
// the 42-day chronic window of HR series each night.
strainTrimpCache StrainTrimpCache[]
strainTrimpCache StrainTrimpCache[]
// v1.11.0 — owner-minted clinician share links. Each row grants a
// scoped, time-limited, revocable read view of the health record to a
// clinician holding the raw `hls_` token (stored only as an HMAC hash).
clinicianShareLinks ClinicianShareLink[]
clinicianShareLinks ClinicianShareLink[]
// v1.11.0 W1 — durable per-provider AI-health ledger (one row per
// provider chain entry) so a known-bad credential is negatively cached
// across workers instead of re-burning a 401 every generation.
providerHealth ProviderHealth[]
providerHealth ProviderHealth[]
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
insightNarratives InsightNarrative[]
insightNarratives InsightNarrative[]
// v1.31.0 — the data-arrival spine's durable per-day reaction marker. One
// row per (kind, local day); the unique index IS the once-per-kind-per-day
// claim every reaction surface reads. Retained 14 days.
arrivalReactions ArrivalReaction[]
workoutInsights WorkoutInsight[]
arrivalReactions ArrivalReaction[]
workoutInsights WorkoutInsight[]
workoutInsightGenerationClaims WorkoutInsightGenerationClaim[]
// v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row
// per user-created reminder ("BP every 7 days", "annual blood panel").
// Server computes the canonical `nextDueAt`; the cron fires through the
// existing notification dispatcher. Soft-deleted.
measurementReminders MeasurementReminder[]
measurementReminders MeasurementReminder[]
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
// that pairs with the Vorsorge "annual blood panel" reminder so a result
// has somewhere to land. Soft-deleted.
labResults LabResult[]
labResults LabResult[]
// v1.19.0 — user-scoped biomarker catalog (canonical name/unit/range per
// marker); LabResult rows link to a Biomarker for the resolved range/unit.
biomarkers Biomarker[]
biomarkers Biomarker[]
// v1.25.5 — user-defined custom metrics. A separate generic store
// (catalog + values), fully isolated from the closed MeasurementType
// system: not synced, not in FHIR, not in AI insights — log + chart only.
customMetrics CustomMetric[]
customMetricEntries CustomMetricEntry[]
customMetrics CustomMetric[]
customMetricEntries CustomMetricEntry[]
// v1.19.0 — illness / condition journal (born-gated by the module system).
// IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the
// per-day timeline mirroring the CycleDayLog shape.
illnessEpisodes IllnessEpisode[]
illnessDayLogs IllnessDayLog[]
illnessEpisodes IllnessEpisode[]
illnessDayLogs IllnessDayLog[]
// v1.25 (W-RECORDS) — structured health-record entries. Allergies map to
// FHIR AllergyIntolerance; family-history entries map to FHIR
// FamilyMemberHistory. Reference records (not time-series signals); the
// sensitive free-text fields are encrypted at rest.
allergies Allergy[]
familyHistory FamilyHistoryEntry[]
allergies Allergy[]
familyHistory FamilyHistoryEntry[]
// v1.25 (W-ENV) — opt-in environmental-context module. `EnvironmentContext`
// is the per-day weather/daylight observation keyed to the resolved location;
// `EnvironmentTravelLocation` holds the manual date-range travel overrides
// that win over the home location for the days they cover.
environmentContexts EnvironmentContext[]
environmentTravelLocations EnvironmentTravelLocation[]
environmentContexts EnvironmentContext[]
environmentTravelLocations EnvironmentTravelLocation[]
// v1.28 — micronutrient intake daily totals synced from Apple Health
// (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off
// by default; the ingest route refuses while the module is off.
nutrientIntakeDays NutrientIntakeDay[]
nutrientIntakeDays NutrientIntakeDay[]
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
// discharge letters) the user uploads. The raw document is encrypted at
// rest; extracted facts land in a review-then-confirm staging area
// (ExtractedFact) and only reach the structured stores on explicit
// approval. Opt-in module, off by default.
inboundDocuments InboundDocument[]
extractedFacts ExtractedFact[]
documentConditionLinks DocumentConditionLink[]
documentContentIndexes DocumentContentIndex[]
documentThumbnails DocumentThumbnail[]
inboundDocuments InboundDocument[]
extractedFacts ExtractedFact[]
documentConditionLinks DocumentConditionLink[]
documentContentIndexes DocumentContentIndex[]
documentThumbnails DocumentThumbnail[]
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
ecgRecordings EcgRecording[]
ecgRecordings EcgRecording[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -1612,8 +1612,8 @@ enum MeasurementAggregationProvenance {
}
model Measurement {
id String @id @default(cuid())
userId String @map("user_id")
id String @id @default(cuid())
userId String @map("user_id")
type MeasurementType
value Float
// v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated row.
@@ -1624,26 +1624,26 @@ model Measurement {
// samples. NULL for every other row (per-sample readings, per-day
// cumulative `stats:` totals, manual entries) — the series reader hands
// them through only for the heart-rate kind.
valueMin Float? @map("value_min")
valueMax Float? @map("value_max")
valueMin Float? @map("value_min")
valueMax Float? @map("value_max")
unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius
source MeasurementSource @default(MANUAL)
measuredAt DateTime @map("measured_at")
source MeasurementSource @default(MANUAL)
measuredAt DateTime @map("measured_at")
notes String? // LEGACY plaintext — superseded by `notesEncrypted` (v1.23). New writes go to the encrypted column and null this; the boot-time backfill migrates the remaining rows. Column drop is a follow-up release once the backfill reports zero remaining.
// v1.23 — free-text measurement note, AES-256-GCM at rest (Bytes, matching
// the IllnessDayLog / CycleDayLog / LabResult note convention). Registered
// in ENCRYPTED_COLUMNS + the rotation script.
notesEncrypted Bytes? @map("notes_encrypted")
notesEncrypted Bytes? @map("notes_encrypted")
// External-system dedup key. Withings used `grpid` (numeric); v1.4.23
// adds Apple Health which uses `HKSample.uuid` (string). NULL for
// manual entries — Postgres treats NULLs as distinct in unique
// indexes, so two manual rows with the same (user, type, source) are
// still allowed.
externalId String? @map("external_id")
externalId String? @map("external_id")
// v1.4.23 — opaque marker for the source's schema version (HealthKit
// sample's `metadata.HKWasUserEntered`, Withings model code, …).
// Stored as a string so the ingest path can replay it back unchanged.
externalSourceVersion String? @map("external_source_version")
externalSourceVersion String? @map("external_source_version")
// Authority of an aggregate stored under a `stats:*` identity. NULL for
// ordinary point/sample rows. Native HealthKit statistics outrank the
// source-day estimate derived from export.xml; migrated ambiguous rows are
@@ -1657,11 +1657,11 @@ model Measurement {
// one direction only: a non-glucose row can never carry a context. Every
// per-context reader selects by equality against the four enum members, so
// a NULL context stays absent rather than reading as a classification.
glucoseContext GlucoseContext? @map("glucose_context")
glucoseContext GlucoseContext? @map("glucose_context")
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
// pattern. A single night produces ~5 rows (one per stage).
sleepStage SleepStage? @map("sleep_stage")
sleepStage SleepStage? @map("sleep_stage")
// v1.10.0 — categorical events (WX-B). The device's own classification
// verdict / severity for an EVENT row. NULL for every continuous
// measurement; non-NULL only for the categorical EVENT `MeasurementType`s.
@@ -1670,7 +1670,7 @@ model Measurement {
// continuous row can never carry a classification. NOT part of any unique
// index — dedup stays on `(userId, type, source, externalId)` keyed by the
// device's `HKSample.uuid`, so a re-posted event collapses to a duplicate.
rhythmClassification RhythmClassification? @map("rhythm_classification")
rhythmClassification RhythmClassification? @map("rhythm_classification")
// v1.4.25 W8c — device-type tag for the cross-source canonical
// picker. Stored as a free-text string (kept simple — Prisma enum
// would require coordinated client + server bumps every time we
@@ -1681,7 +1681,7 @@ model Measurement {
// NULL as `unknown`. Unique constraints intentionally do not
// include `deviceType` so two ingest paths from the same source
// still dedup on (type, source, externalId).
deviceType String? @map("device_type")
deviceType String? @map("device_type")
// v1.4.30 — iOS SyncMode foundation (R-E C-2). Monotonically
// incremented on every server-side mutation of the row. iOS uses
// it for last-writer-wins reconciliation when two devices write
@@ -1689,16 +1689,16 @@ model Measurement {
// update path is responsible for bumping it. Existing rows
// start at the default after the additive migration so legacy
// ingest keeps round-tripping unchanged.
syncVersion Int @default(1) @map("sync_version")
syncVersion Int @default(1) @map("sync_version")
// v1.4.30 — soft-delete column. The DELETE path on the
// by-external-ids endpoint flips this rather than removing the row
// so paired iOS clients can reconcile deletions without losing the
// audit trail. NULL = live row; non-NULL = tombstoned at the
// recorded instant. The unique-by-externalId index keeps both
// states distinguishable for the bulk-backfill UPSERT path.
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// v1.19.0 — optional back-relation to the full ECG waveform recording(s)
@@ -1976,10 +1976,10 @@ model Workout {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
route WorkoutRoute?
samples WorkoutSamples?
insight WorkoutInsight?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
route WorkoutRoute?
samples WorkoutSamples?
insight WorkoutInsight?
insightGenerationClaim WorkoutInsightGenerationClaim?
/// Ingest dedup. NULL externalId is distinct so two manual
@@ -2086,7 +2086,6 @@ model WorkoutSamples {
// ever creates one: a workout with no row renders no card, and that includes
// every workout that predates this model.
model WorkoutInsight {
id String @id @default(cuid())
userId String @map("user_id")
workoutId String @unique @map("workout_id")
@@ -2437,7 +2436,7 @@ model Medication {
schedules MedicationSchedule[]
intakeEvents MedicationIntakeEvent[]
intakeImportJobs MedicationIntakeImportJob[]
intakeImportJobs MedicationIntakeImportJob[]
phaseConfig ReminderPhaseConfig?
telegramMessages TelegramReminderMessage[]
doseChanges MedicationDoseChange[]
@@ -3242,8 +3241,8 @@ model ApiToken {
// 60-minute access rows so it does not flood (M2).
mcpConnectionId String? @map("mcp_connection_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
// v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it.
stepUpElevations StepUpElevation[]
@@ -3479,13 +3478,13 @@ model OidcNativeHandoff {
// ─── Withings ──────────────────────────────────────────────
model WithingsConnection {
id String @id @default(cuid())
userId String @unique @map("user_id")
withingsUserId String @map("withings_user_id")
accessToken String @map("access_token") // encrypted at app level
refreshToken String @map("refresh_token") // encrypted at app level
tokenExpiresAt DateTime @map("token_expires_at")
lastSyncedAt DateTime? @map("last_synced_at")
id String @id @default(cuid())
userId String @unique @map("user_id")
withingsUserId String @map("withings_user_id")
accessToken String @map("access_token") // encrypted at app level
refreshToken String @map("refresh_token") // encrypted at app level
tokenExpiresAt DateTime @map("token_expires_at")
lastSyncedAt DateTime? @map("last_synced_at")
/// v1.4.25 W5d — comma-separated OAuth scope string returned by the
/// Withings authorisation flow. Legacy v1.4.24- connections sit on
/// `user.metrics` only; v1.4.25 starts requesting
@@ -3493,21 +3492,21 @@ model WithingsConnection {
/// activity / sleep endpoints unlock. Null = legacy connection that
/// has never re-authed since v1.4.25 — the UI surfaces a banner
/// inviting them to reconnect.
scope String? @map("scope")
scope String? @map("scope")
/// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings
/// sleep rows were stamped with the segment START; the reader treats
/// `measuredAt` as the END, shifting every night one segment-length earlier.
/// The boot-time backfill deletes the affected WITHINGS / SLEEP_DURATION rows
/// and re-syncs with the corrected END stamp, then sets this so the discovery
/// query drops the connection. Null = backfill not yet run.
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
/// v1.32 — durable per-category webhook subscription outcomes. The JSON
/// payload is versioned and validated before use; the timestamp mirrors the
/// earliest transient/pending category retry for indexed hourly discovery.
webhookSubscriptionState Json? @map("webhook_subscription_state")
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -4476,7 +4475,14 @@ model ImportJob {
model MedicationIntakeImportJob {
id String @id @default(cuid())
userId String @map("user_id")
medicationId String @map("medication_id")
/// v1.32.40 (migration 0276) — nullable. The per-medication import route
/// scopes its job to one medication and names it here. A dose history
/// exported from another tracker covers a whole regimen in one file — the
/// reported export named sixteen medications across 3,395 rows — so that job
/// belongs to the account and each payload entry names its own medication
/// instead. NULL therefore means "account-wide", not "unknown": the worker
/// reads `entry.medicationId` and refuses a payload that names neither.
medicationId String? @map("medication_id")
pgBossJobId String? @unique @map("pg_boss_job_id")
status String @default("queued")
payload Json
@@ -4489,8 +4495,8 @@ model MedicationIntakeImportJob {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
medication Medication? @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([medicationId, createdAt])
@@ -5424,13 +5430,13 @@ model ClinicianShareLink {
// ── Frozen scope (write-once at creation; never widened) ──
/// Reporting-window start (absolute, not "last N days"). Frozen so a
/// rolling share can never reach data older than the owner chose.
rangeStart DateTime @map("range_start")
rangeStart DateTime @map("range_start")
/// Window end. Null = rolling ("up to now"); set = frozen snapshot window.
rangeEnd DateTime? @map("range_end")
rangeEnd DateTime? @map("range_end")
/// The frozen report scope: `{ "v": 2, "leaves": [...] }`. Write-once at
/// mint, never widened. A blob this build cannot read resolves to the empty
/// selection, so a drifted row serves nothing rather than defaulting open.
sectionsJson Json @map("sections_json")
sectionsJson Json @map("sections_json")
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
/// create. When true the public view serves ONLY the attached documents and
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
@@ -5439,7 +5445,7 @@ model ClinicianShareLink {
/// documents-only link (the previous derived check would have leaked it once
/// the new key defaulted on). Legacy links (pre-column) keep the derived
/// all-off fallback in the view loader.
documentOnly Boolean @default(false) @map("document_only")
documentOnly Boolean @default(false) @map("document_only")
/// REQUIRED — no never-expiring share (capped at `SHARE_LINK_MAX_DAYS`).
expiresAt DateTime @map("expires_at")
@@ -5543,38 +5549,38 @@ model InsightNarrative {
// revision owns generation. A strictly newer arrival atomically invalidates
// stale generation state; equal or older deliveries are deduplicated.
model ArrivalReaction {
id String @id @default(cuid())
userId String @map("user_id")
id String @id @default(cuid())
userId String @map("user_id")
/// One of `ARRIVAL_KINDS` (`@/lib/arrivals/types`).
kind String
kind String
/// The user's PROFILE-timezone YYYY-MM-DD this reaction is filed under.
localDate String @map("local_date")
localDate String @map("local_date")
/// Timestamp of the newest sample that triggered it.
occurredAt DateTime @map("occurred_at")
occurredAt DateTime @map("occurred_at")
/// When the newest sample actually reached HealthLog. Unlike `createdAt`,
/// this advances when a later same-kind arrival refreshes the daily marker.
arrivedAt DateTime @default(now()) @map("arrived_at")
arrivedAt DateTime @default(now()) @map("arrived_at")
/// Kind-scoped referent: the workout id for `workout`, the panel day for labs.
refId String? @map("ref_id")
refId String? @map("ref_id")
/// AES-256-GCM ciphertext of the generated reaction line. Null until — and
/// unless — a line is generated: the marker is the feature, the sentence is
/// garnish, and a provider-less install never fills this in. Follows the
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
/// the status cards use. Registered in `scripts/rotate-encryption-key.ts`.
lineEncrypted Bytes? @map("line_encrypted")
generatedAt DateTime? @map("generated_at")
lineEncrypted Bytes? @map("line_encrypted")
generatedAt DateTime? @map("generated_at")
/// Opaque owner token and lease for pre-provider setup. A stale worker may
/// reclaim only while `generationProviderInvokedAt` is null.
generationClaimId String? @map("generation_claim_id")
generationClaimedAt DateTime? @map("generation_claimed_at")
generationClaimId String? @map("generation_claim_id")
generationClaimedAt DateTime? @map("generation_claimed_at")
/// The token reservation is written in the same transaction as the
/// `coach_usage` increment. A reclaimed pre-provider attempt reuses it.
generationReservedTokens Int? @map("generation_reserved_tokens")
generationBudgetDateKey String? @map("generation_budget_date_key")
generationReservedTokens Int? @map("generation_reserved_tokens")
generationBudgetDateKey String? @map("generation_budget_date_key")
/// Durable provider boundary. Once set, this marker is terminal unless the
/// encrypted line is committed; no retry may invoke a provider again.
generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
createdAt DateTime @default(now()) @map("created_at")
generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -2,8 +2,8 @@ import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readMedicationIntakeImportJob } from "@/lib/medications/intake-import-job-status";
interface RouteContext {
params: Promise<{ id: string; jobId: string }>;
@@ -20,20 +20,12 @@ export const GET = apiHandler(
meta: { job_id: jobId, medication_id: medicationId },
});
const row = await prisma.medicationIntakeImportJob.findFirst({
where: { id: jobId, medicationId, userId: user.id },
});
if (!row) return apiError("Import job not found", 404);
return apiSuccess({
jobId: row.id,
status: row.status,
progress: row.progress,
result: row.result,
failureReason: row.failureReason,
createdAt: row.createdAt.toISOString(),
startedAt: row.startedAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
});
const job = await readMedicationIntakeImportJob(
user.id,
jobId,
medicationId,
);
if (!job) return apiError("Import job not found", 404);
return apiSuccess(job);
},
);
@@ -0,0 +1,30 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readMedicationIntakeImportJob } from "@/lib/medications/intake-import-job-status";
interface RouteContext {
params: Promise<{ jobId: string }>;
}
export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteContext) => {
const { user } = await requireAuth();
const { jobId } = await params;
annotate({
action: { name: "medication.intake.history.import.status" },
meta: { job_id: jobId },
});
// No medication argument: this reads the account-wide job, and the helper
// narrows on `medicationId: null` so a per-medication job id cannot be read
// through here (or the other way round).
const job = await readMedicationIntakeImportJob(user.id, jobId);
if (!job) return apiError("Import job not found", 404);
return apiSuccess(job);
},
);
@@ -0,0 +1,317 @@
/**
* Import a dose history exported from another medication tracker.
*
* The existing per-medication import takes a hand-written list of dates and
* times for one medication. That is what a self-hoster with a ten-column,
* 3,395-row export covering sixteen medications was left translating by hand,
* guessing at what each field meant. This route reads that file's own shape, so
* nobody has to.
*
* What the shape earns it: the export keeps the scheduled time and the time the
* dose was actually taken in two separate columns, which is exactly the pair
* `MedicationIntakeEvent` already models as `scheduledFor` + `takenAt`. That
* distinction is the first casualty of translating a file by hand, and it is the
* one that decides whether a dose reads as on time or late for the rest of its
* life.
*
* Body is the file's raw text — `text/csv` for the ten-column export,
* `application/json` for the documented JSON shape. Capped at 16 MB, the same
* ceiling the measurement CSV import carries.
*
* `?dryRun=1` parses, matches every row against the record, and returns the
* whole verdict WITHOUT writing: how many rows land, how many are refused and
* for which reason, which medication names match nothing. A bulk import of years
* of history should be inspectable before it happens.
*
* The write itself runs on the same background job the per-medication import
* uses — chunked, heartbeated, resumable, with the rollup recompute chunked
* behind it. A path that works on 28 rows and dies on 30,000 is not an import.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
MEDICATION_INTAKE_IMPORT_QUEUE,
MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
groupMedicationImportSkips,
totalMedicationImportSkips,
type MedicationImportPayload,
type MedicationImportProgress,
} from "@/lib/jobs/medication-intake-import";
import { annotate } from "@/lib/logging/context";
import {
parseAutoExportCsv,
parseAutoExportJson,
} from "@/lib/medications/import/auto-export-parse";
import {
planAutoExportImport,
type AutoExportPlan,
} from "@/lib/medications/import/auto-export-plan";
import { checkRateLimit } from "@/lib/rate-limit";
/** Same ceiling as the measurement CSV import. */
const MAX_BYTES = 16 * 1024 * 1024;
/**
* Rows the importer will queue in one submission.
*
* The write loop reads the whole payload out of the job row on every chunk, so
* the cost of a submission grows with rows × chunks. At 30,000 rows that is a
* few hundred megabytes of read across a couple of hundred short transactions —
* slow but bounded, heartbeated, and resumable. Beyond it the shape stops being
* the right one, and saying so is better than accepting a file that then crawls.
*/
const MAX_QUEUED_ROWS = 30_000;
/**
* Names echoed back so the person can act on them. Bounded: an unmatched name is
* a hint, and a file naming five hundred unknown medications needs a different
* conversation than a longer list.
*/
const MAX_NAMES_REPORTED = 50;
/** Blast-radius bucket shared with every other bulk import surface. */
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
function cap(names: readonly string[]): string[] {
return names.slice(0, MAX_NAMES_REPORTED);
}
/** The file-level verdict, identical for a dry run and a real submission. */
function describePlan(plan: AutoExportPlan) {
return {
rowsRead: plan.rowsRead,
queued: plan.entries.length,
refused: totalMedicationImportSkips(plan.skippedByReason),
refusedByReason: groupMedicationImportSkips(plan.skippedByReason),
unmatchedMedications: cap(plan.unmatchedMedications),
ambiguousMedications: cap(plan.ambiguousMedications),
mirroredMedications: cap(plan.mirroredMedications),
unknownColumns: cap(plan.unknownColumns),
codingsNotRead: plan.csvCodingsIgnored,
fromArchivedMedications: plan.fromArchivedMedications,
};
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const dryRunParam = new URL(request.url).searchParams.get("dryRun");
const dryRun = dryRunParam === "1" || dryRunParam === "true";
annotate({
action: { name: "medication.intake.history.import" },
meta: { dry_run: dryRun },
});
const rl = await checkRateLimit(
`import:${user.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError(`Maximum ${RATE_LIMIT_MAX} imports per hour`, 429);
}
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
const isJson = contentType.includes("application/json");
const isText =
contentType.includes("text/csv") || contentType.includes("text/plain");
if (!isJson && !isText) {
return apiError("Content-Type must be text/csv or application/json", 415);
}
const declaredLength = Number(request.headers.get("content-length") ?? "0");
if (Number.isFinite(declaredLength) && declaredLength > MAX_BYTES) {
return apiError("File exceeds the 16 MB limit", 413);
}
let text: string;
try {
text = await request.text();
} catch {
return apiError("Could not read the request body", 400);
}
if (Buffer.byteLength(text, "utf8") > MAX_BYTES) {
return apiError("File exceeds the 16 MB limit", 413);
}
const parsed = isJson ? parseAutoExportJson(text) : parseAutoExportCsv(text);
if (parsed.fatal) {
annotate({
action: { name: "medication.intake.history.import.refused" },
meta: { reason: parsed.fatal.reason },
});
// The reason travels as a machine-readable code so the client can render the
// sentence that names the actual problem — a JSON export that carries no
// intake time needs a different answer than a truncated file.
return apiError("The file could not be read", 422, {
errorCode: parsed.fatal.reason,
...(parsed.fatal.detail === undefined
? {}
: { detail: parsed.fatal.detail }),
});
}
const medications = await prisma.medication.findMany({
where: { userId: user.id },
// Every medication, active or not: a dose taken in 2023 belongs to the
// medication whether or not the person has since stopped taking it.
select: { id: true, name: true, externalSource: true },
});
const plan = planAutoExportImport(parsed, medications);
if (plan.entries.length > MAX_QUEUED_ROWS) {
return apiError(
`The file holds ${plan.entries.length} importable rows — the limit is ${MAX_QUEUED_ROWS} per import`,
422,
{ errorCode: "too_many_rows" },
);
}
const file = describePlan(plan);
if (dryRun) {
return apiSuccess({ dryRun: true, jobId: null, statusUrl: null, file });
}
const payload: MedicationImportPayload = { entries: plan.entries };
// Rows refused before the queue are seeded into the progress the worker
// advances, so the count the person reads at the end covers the whole file and
// not just the part that reached the database.
const progress: MedicationImportProgress = {
processed: 0,
total: plan.entries.length,
imported: 0,
skippedByReason: plan.skippedByReason,
touchedDays: [],
rollupProcessed: 0,
};
const admission = await prisma.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT "id"
FROM "users"
WHERE "id" = ${user.id}
FOR UPDATE
`;
const now = new Date();
const staleBefore = new Date(
now.getTime() - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
);
await tx.medicationIntakeImportJob.updateMany({
where: {
userId: user.id,
medicationId: null,
status: { in: ["queued", "running"] },
OR: [
{ heartbeatAt: { lt: staleBefore } },
{ heartbeatAt: null, startedAt: { lt: staleBefore } },
{
heartbeatAt: null,
startedAt: null,
createdAt: { lt: staleBefore },
},
],
},
data: {
status: "failed",
failureReason: "Medication intake import abandoned",
heartbeatAt: now,
completedAt: now,
},
});
const activeJob = await tx.medicationIntakeImportJob.findFirst({
where: {
userId: user.id,
medicationId: null,
status: { in: ["queued", "running"] },
},
select: { id: true },
});
if (activeJob) return { active: true as const };
const importJob = await tx.medicationIntakeImportJob.create({
data: {
userId: user.id,
medicationId: null,
status: "queued",
payload: toJson(payload),
progress: toJson(progress),
},
});
return { active: false as const, importJob };
});
if (admission.active) {
return apiError("A dose-history import is already in progress", 409);
}
const { importJob } = admission;
const statusUrl = `/api/medications/intake/dose-history-import/${importJob.id}/status`;
let bossJobId: string | null = null;
try {
const boss = getGlobalBoss();
if (!boss) throw new Error("worker unavailable");
bossJobId = await boss.send(
MEDICATION_INTAKE_IMPORT_QUEUE,
{ jobId: importJob.id },
MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
);
if (!bossJobId) throw new Error("queue rejected job");
await prisma.medicationIntakeImportJob.update({
where: { id: importJob.id },
data: { pgBossJobId: bossJobId },
});
} catch {
const completedAt = new Date();
await prisma.medicationIntakeImportJob.update({
where: { id: importJob.id },
data: {
status: "failed",
failureReason: "Background worker enqueue failed",
heartbeatAt: completedAt,
completedAt,
},
});
await auditLog("medication.intake.import.kickoff.denied", {
userId: user.id,
ipAddress: getClientIp(request),
details: { jobId: importJob.id, reason: "enqueue_failed" },
});
return apiError("Background worker is not available", 503);
}
await auditLog("medication.intake.history.import.kickoff", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
jobId: importJob.id,
bossJobId,
rowsRead: file.rowsRead,
queued: file.queued,
refused: file.refused,
},
});
annotate({
action: { name: "medication.intake.history.import.kickoff" },
meta: {
job_id: importJob.id,
rows_read: file.rowsRead,
queued: file.queued,
refused: file.refused,
},
});
return apiSuccess(
{
dryRun: false,
jobId: importJob.id,
status: "queued" as const,
statusUrl,
file,
},
202,
);
});
+194 -36
View File
@@ -24,8 +24,28 @@ export const MEDICATION_INTAKE_IMPORT_SEND_OPTIONS = {
} as const;
export interface MedicationImportEntry {
takenAt: string;
/**
* The slot instant this dose belongs to, and the fact it is identified by.
* Equals `takenAt` for an ad-hoc dose that belongs to no slot.
*/
scheduledFor: string;
/** When the dose was actually taken. `null` for a deliberate skip. */
takenAt: string | null;
idempotencyKey: string;
/**
* The medication this dose attaches to. Carried per entry because a dose
* history exported from another app covers a whole regimen in one file, so the
* job cannot be scoped to a single medication the way the per-medication
* import route's job is. Absent on that route's jobs, where the job row names
* the medication instead.
*/
medicationId?: string;
/**
* Per-dose override, free text, mirroring `Medication.dose`. Absent when the
* configured schedule dose applies — which is the overwhelming majority, and
* absence is how `MedicationIntakeEvent.doseTaken` already says so.
*/
doseTaken?: string;
}
export interface MedicationImportPayload {
@@ -33,22 +53,40 @@ export interface MedicationImportPayload {
}
/**
* Why a submitted entry did not become a row. Machine-readable; the client
* turns each into a sentence.
* Why a submitted row did not become a row on the ledger. Machine-readable; the
* client turns each into a sentence.
*
* `duplicate_in_file` — two entries in the same submission resolve to the same
* instant, so only one can be written.
* `already_recorded` — the dose is already on the ledger for that instant, from
* an earlier import or from any other intake surface.
* `duplicate_in_file` — two rows of the same submission resolve to the same
* medication and slot, so only one can be written.
* `already_recorded` — the dose is already on the ledger for that slot, from an
* earlier import or from any other intake surface.
*
* They used to be one `skippedDuplicates` number, which is how a file whose
* every row collapsed onto one key could report "27 duplicates skipped" about
* 27 doses that had no duplicate anywhere. Naming the reason is what makes the
* count checkable against what the person actually submitted.
* Those two used to be one `skippedDuplicates` number, which is how a file whose
* every row collapsed onto one key could report "27 duplicates skipped" about 27
* doses that had no duplicate anywhere. Naming the reason is what makes the count
* checkable against what the person actually submitted.
*
* The rest are decided before the queue, when a whole export file is parsed and
* matched against the record. They are seeded into the job's progress so the
* final count covers the entire file rather than only the part that reached the
* database — a row refused at the door is still a row the person needs told
* about, by name.
*/
export const MEDICATION_IMPORT_SKIP_REASONS = [
"duplicate_in_file",
"already_recorded",
"medication_not_found",
"medication_ambiguous",
"medication_is_mirrored",
"status_not_recorded",
"status_unknown",
"missing_timestamp",
"missing_timezone_offset",
"unreadable_timestamp",
"implausible_timestamp",
"missing_medication",
"unreadable_dosage",
"unreadable_row",
] as const;
export type MedicationImportSkipReason =
@@ -197,16 +235,49 @@ function parsePayload(value: Prisma.JsonValue): MedicationImportPayload {
typeof entry !== "object" ||
entry === null ||
Array.isArray(entry) ||
typeof entry.takenAt !== "string" ||
!Number.isFinite(Date.parse(entry.takenAt)) ||
typeof entry.idempotencyKey !== "string" ||
entry.idempotencyKey.length === 0
) {
throw new Error("Medication intake import entry is malformed");
}
const takenAt = entry.takenAt;
if (
takenAt !== null &&
(typeof takenAt !== "string" || !Number.isFinite(Date.parse(takenAt)))
) {
throw new Error("Medication intake import entry is malformed");
}
// `scheduledFor` was added when the importer learnt to read an export that
// states the scheduled time and the time taken as two separate facts. A job
// row enqueued before that carries only `takenAt`, and such a row is an
// ad-hoc dose anchored on itself — which is what the absence means. This is
// for rows already in the database, not for a caller.
const rawScheduledFor =
entry.scheduledFor === undefined ? takenAt : entry.scheduledFor;
if (
typeof rawScheduledFor !== "string" ||
!Number.isFinite(Date.parse(rawScheduledFor))
) {
throw new Error("Medication intake import entry is malformed");
}
if (
entry.medicationId !== undefined &&
(typeof entry.medicationId !== "string" ||
entry.medicationId.length === 0)
) {
throw new Error("Medication intake import entry is malformed");
}
if (entry.doseTaken !== undefined && typeof entry.doseTaken !== "string") {
throw new Error("Medication intake import entry is malformed");
}
return {
takenAt: entry.takenAt,
scheduledFor: rawScheduledFor,
takenAt: takenAt as string | null,
idempotencyKey: entry.idempotencyKey,
...(entry.medicationId === undefined
? {}
: { medicationId: entry.medicationId }),
...(entry.doseTaken === undefined ? {} : { doseTaken: entry.doseTaken }),
};
});
return { entries };
@@ -287,23 +358,64 @@ interface ProcessChunkOutcome {
userId: string | null;
}
/**
* A day whose compliance rollup an import invalidated, as one string.
*
* A job can now span a whole regimen, so a bare day key no longer identifies
* what to recompute — two medications touched on the same day are two rollup
* rows. The medication is folded in ahead of a single space, which occurs in
* neither a cuid nor a `YYYY-MM-DD` key. Not a NUL byte: this value is persisted
* in the progress blob and Postgres rejects `` inside a jsonb string.
*/
const TOUCHED_DAY_SEPARATOR = "";
function encodeTouchedDay(medicationId: string, dayKey: string): string {
return `${medicationId}${TOUCHED_DAY_SEPARATOR}${dayKey}`;
}
/**
* Split a stored touched-day back into its parts.
*
* A value with no separator was written before a job could span more than one
* medication, so it is a bare day key belonging to the job's own medication.
* Returns null when neither reading yields a medication — a per-medication job
* that lost its medication cannot be resolved and the day is left alone rather
* than recomputed against a guess.
*/
function decodeTouchedDay(
value: string,
fallbackMedicationId: string | null,
): { medicationId: string; dayKey: string } | null {
const separator = value.indexOf(TOUCHED_DAY_SEPARATOR);
if (separator === -1) {
if (!fallbackMedicationId) return null;
return { medicationId: fallbackMedicationId, dayKey: value };
}
const medicationId = value.slice(0, separator);
const dayKey = value.slice(separator + 1);
if (medicationId.length === 0 || dayKey.length === 0) return null;
return { medicationId, dayKey };
}
async function recomputeTouchedDays(
client: Pick<PrismaClient, "$executeRaw">,
userId: string,
medicationId: string,
jobMedicationId: string | null,
timezone: string,
days: readonly string[],
): Promise<void> {
await Promise.all(
days.map((day) =>
recomputeMedicationComplianceForDay(
days.map((day) => {
const decoded = decodeTouchedDay(day, jobMedicationId);
if (!decoded) return Promise.resolve();
return recomputeMedicationComplianceForDay(
userId,
medicationId,
day,
decoded.medicationId,
decoded.dayKey,
timezone,
client,
),
),
);
}),
);
}
@@ -426,35 +538,81 @@ async function processNextChunk(
entries.map((entry) => [entry.idempotencyKey, entry]),
).values(),
];
// An entry names its own medication when the job came from an export file
// covering a whole regimen; the per-medication route's jobs name it on the
// job row instead. One of the two must be there, and a payload with
// neither is a malformed job rather than a row to write against a guess.
const resolveMedicationId = (entry: MedicationImportEntry): string => {
const medicationId = entry.medicationId ?? row.medicationId;
if (!medicationId) {
throw new Error("Medication intake import entry names no medication");
}
return medicationId;
};
const created = await tx.medicationIntakeEvent.createManyAndReturn({
data: uniqueEntries.map((entry) => {
const takenAt = new Date(entry.takenAt);
const scheduledFor = new Date(entry.scheduledFor);
const takenAt =
entry.takenAt === null ? null : new Date(entry.takenAt);
return {
userId: row.userId,
medicationId: row.medicationId,
scheduledFor: takenAt,
medicationId: resolveMedicationId(entry),
// The scheduled slot and the time taken are two separate facts, and
// the export states both. Writing the take into both columns — which
// is all the old two-field payload could express — asserts every
// dose landed exactly on time, and no later edit recovers what that
// overwrote.
scheduledFor,
takenAt,
skipped: false,
// A null take on an imported row is a deliberate skip: the source
// recorded that the person chose not to take that dose.
skipped: takenAt === null,
source: "IMPORT" as const,
idempotencyKey: entry.idempotencyKey,
...(entry.doseTaken === undefined
? {}
: { doseTaken: entry.doseTaken }),
};
}),
skipDuplicates: true,
select: { id: true, takenAt: true, scheduledFor: true },
select: {
id: true,
medicationId: true,
takenAt: true,
scheduledFor: true,
},
});
await consumeImportedIntakesBatch({
client: tx,
userId: row.userId,
medicationId: row.medicationId,
events: created.map((event) => ({
eventId: event.id,
intakeAt: event.takenAt ?? event.scheduledFor,
})),
});
// Inventory is per medication and a skip consumes nothing, so the created
// rows are grouped and the skips left out. `consumeImportedIntakesBatch`
// asserts it claimed every event handed to it, which only holds for rows
// that actually carry a take.
const consumedByMedication = new Map<
string,
Array<{ eventId: string; intakeAt: Date }>
>();
for (const event of created) {
if (event.takenAt === null) continue;
const bucket = consumedByMedication.get(event.medicationId);
const entry = { eventId: event.id, intakeAt: event.takenAt };
if (bucket) bucket.push(entry);
else consumedByMedication.set(event.medicationId, [entry]);
}
for (const [medicationId, events] of consumedByMedication) {
await consumeImportedIntakesBatch({
client: tx,
userId: row.userId,
medicationId,
events,
});
}
const touchedDays = created.map((event) =>
dayKeyForScheduledFor(event.scheduledFor, row.user.timezone),
encodeTouchedDay(
event.medicationId,
dayKeyForScheduledFor(event.scheduledFor, row.user.timezone),
),
);
// Two different facts, counted apart. The Map collapse above is entries
// of this submission that resolve to the same instant; `skipDuplicates`
@@ -0,0 +1,180 @@
/**
* The medication dose-history export format, read directly.
*
* A self-hoster was hand-mapping this export into the intake importer's
* two-field shape and guessing at what belonged where. Hand-mapping is what
* destroyed the part that matters: the export keeps the scheduled time and the
* time actually taken as two separate columns, which is exactly the pair
* `MedicationIntakeEvent` models as `scheduledFor` + `takenAt`. Flattening the
* file collapses them into one.
*
* Two shapes exist. The CSV carries ten columns; the JSON carries the
* documented per-dose object. Both are read here, and every column gets a
* verdict that the importing person can read before choosing a file — a column
* dropped in silence is the failure this format work exists to remove.
*
* Nothing in this module touches a database. It is the vocabulary and the
* verdicts; `auto-export-parse.ts` applies them.
*/
/** The CSV header, in the order the export writes it. */
export const AUTO_EXPORT_CSV_COLUMNS = [
"Date",
"Scheduled Date",
"Medication",
"Nickname",
"Dosage",
"Scheduled Dosage",
"Unit",
"Status",
"Archived",
"Codings",
] as const;
export type AutoExportCsvColumn = (typeof AUTO_EXPORT_CSV_COLUMNS)[number];
/**
* What the importer does with a column.
*
* `honoured` — the value reaches the record, or decides whether a row does.
* `reported` — read, never stored; it appears in the run summary so the
* person can see what the file said.
* `ignored` — deliberately not read, with a stated reason.
*/
export type AutoExportColumnVerdict = "honoured" | "reported" | "ignored";
export interface AutoExportColumnRuling {
column: string;
verdict: AutoExportColumnVerdict;
/**
* Stable key for the sentence explaining the ruling. The UI resolves it with
* a literal `t()` call per key so the i18n call-site guard sees every one.
*/
noteKey: string;
/** True for a field only the JSON shape carries. */
jsonOnly?: boolean;
}
/**
* Every field of both shapes with its ruling. Exhaustive by construction: a
* structural test asserts each CSV column and each documented JSON field
* appears exactly once, so a column cannot be added to the parser without a
* verdict landing here.
*/
export const AUTO_EXPORT_COLUMN_RULINGS: readonly AutoExportColumnRuling[] = [
{ column: "Date", verdict: "honoured", noteKey: "date" },
{ column: "Scheduled Date", verdict: "honoured", noteKey: "scheduledDate" },
{ column: "Medication", verdict: "honoured", noteKey: "medication" },
{ column: "Nickname", verdict: "honoured", noteKey: "nickname" },
{ column: "Dosage", verdict: "honoured", noteKey: "dosage" },
{
column: "Scheduled Dosage",
verdict: "honoured",
noteKey: "scheduledDosage",
},
{ column: "Unit", verdict: "honoured", noteKey: "unit" },
{ column: "Status", verdict: "honoured", noteKey: "status" },
{ column: "Archived", verdict: "reported", noteKey: "archived" },
{ column: "Codings", verdict: "ignored", noteKey: "codings" },
{ column: "form", verdict: "ignored", noteKey: "form", jsonOnly: true },
{ column: "start", verdict: "ignored", noteKey: "start", jsonOnly: true },
{ column: "end", verdict: "ignored", noteKey: "end", jsonOnly: true },
] as const;
/**
* Documented JSON field names. `displayText` is the JSON spelling of the CSV's
* `Medication`, `isArchived` of `Archived`, `scheduledDate` of `Scheduled
* Date`; the CSV's `Scheduled Dosage` has no JSON counterpart at all.
*/
export const AUTO_EXPORT_JSON_FIELDS = [
"displayText",
"nickname",
"form",
"start",
"end",
"scheduledDate",
"status",
"isArchived",
"dosage",
"codings",
] as const;
/**
* Every status the format documents.
*
* `Taken` and `Skipped` each name a decision the person made about a dose, and
* HealthLog already models both — a taken row and a deliberate skip. The rest
* name the absence of a decision: nobody acted, or the app never asked. There
* is no honest row to write for those. Turning "nobody acted" into a missed
* dose would be the importer inventing a compliance fact the export never
* asserted, and writing them as still-pending would put outstanding doses from
* 2023 back in front of the person today.
*/
export const AUTO_EXPORT_STATUSES = [
"Taken",
"Skipped",
"Not Interacted",
"Notification Not Sent",
"Snoozed",
"Not Logged",
"Unspecified",
] as const;
export type AutoExportStatus = (typeof AUTO_EXPORT_STATUSES)[number];
/** The two statuses that become a row. */
export const AUTO_EXPORT_ACTIONABLE_STATUSES = ["Taken", "Skipped"] as const;
export function isAutoExportStatus(value: string): value is AutoExportStatus {
return (AUTO_EXPORT_STATUSES as readonly string[]).includes(value);
}
export function isActionableAutoExportStatus(value: string): boolean {
return (AUTO_EXPORT_ACTIONABLE_STATUSES as readonly string[]).includes(value);
}
/**
* Where the export's codings WOULD land, and why they do not land there today.
*
* The format documents `codings` as `[{ code, system, version }]` with systems
* such as RxNorm and NDC. HealthLog already holds two of those: `atcCode`
* (WHO ATC) and `rxNormCode` (RxCUI) on `Medication`. A code is a far stronger
* identity than a display name someone may have typed differently in each app,
* so it is the natural way to match a row to a medication.
*
* It is not read, for two reasons that are about evidence rather than effort.
* The CSV shape — the only shape that carries a dose history at all — gives the
* `Codings` column no documented encoding, and the sample export leaves it empty
* on every row, so there is nothing to derive a reader from and no way to test
* one. The JSON shape does document the structure but carries no intake time, so
* it is refused whole. A populated `Codings` cell is therefore counted and
* reported rather than guessed at.
*
* If a shape turns up that carries both, these are the columns waiting for it —
* for matching only. `atcCode` and `rxNormCode` are asserted by the person or
* their clinician, and an import file is neither.
*/
/**
* Fold a dose amount and its unit into the free-text per-dose override.
*
* `count` is the export's word for "this many of the configured dose" and
* carries nothing about substance amount, so the number stands alone. Any other
* unit is echoed exactly as the file wrote it — `0.4 mL`, not a translated or
* re-cased guess at which of HealthLog's own unit keys was meant.
*/
export function formatAutoExportDose(amount: number, unit: string): string {
const trimmed = unit.trim();
// `String` already drops a trailing `.0`, which is how the export writes
// whole amounts: `1.0` becomes `1`, `0.25` stays `0.25`.
const rendered = String(amount);
if (trimmed.length === 0 || trimmed.toLowerCase() === "count") {
return rendered;
}
return `${rendered} ${trimmed}`;
}
/** Normalise a medication name for matching: trimmed, folded, case-blind. */
export function normaliseMedicationName(value: string): string {
return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
}
@@ -0,0 +1,127 @@
/**
* Reading an instant that carries its own UTC offset.
*
* The medication export writes `2023-02-16 08:38:00 +1030` — a space instead
* of `T`, and a compact offset with no colon. Neither form is ISO 8601, so
* `new Date(...)` on it is engine-dependent leniency rather than a contract:
* V8 happens to accept it, another runtime need not, and a silent `Invalid
* Date` or a reinterpretation in the server's zone would move every dose in
* the file by hours. A file spanning an Australian summer carries `+1030` and
* `+0930` on adjacent rows, so the offset is per-row data, not a file-level
* setting to be guessed at once.
*
* So the offset is read explicitly and the epoch computed arithmetically:
* `Date.UTC(wall clock) - offset`. Nothing here parses a date from a string
* through the platform. A value without an offset is refused rather than
* assumed — the server's zone is not evidence about where the dose was taken.
*
* `src/lib/tz/` is the project's timezone layer and it stays the authority for
* the other direction (an instant plus a zone name to a local day key, which
* is what the compliance rollups key on). It carries no parser, by design: it
* converts an offset-less wall clock plus a zone NAME to UTC. An offset is not
* a zone name, so this is the one thing it cannot do, and this module is
* deliberately the narrowest possible complement to it.
*/
/** Widest real UTC offset in either direction (±14:00, Kiritimati). */
const MAX_OFFSET_MINUTES = 14 * 60;
/**
* `YYYY-MM-DD` `HH:MM:SS` with an optional fractional second, then either `Z`
* or a `±HHMM` / `±HH:MM` offset. Both the space and the `T` separator are
* accepted: the CSV uses a space, and a hand-edited file may well use `T`.
*/
const OFFSET_INSTANT_RE =
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?\s*(?:(Z|z)|([+-])(\d{2}):?(\d{2}))$/;
export type AutoExportInstantFailure =
/** The cell was blank. Distinct from unreadable: nothing was stated. */
| "absent"
/** Shaped like a timestamp but carries no offset, so the instant is unknown. */
| "missing_offset"
/** Does not match the documented `yyyy-MM-dd HH:mm:ss Z` shape at all. */
| "unreadable"
/** Matches the shape but names no real instant (month 13, offset ±99:00). */
| "out_of_range";
export type AutoExportInstantResult =
| { ok: true; instant: Date; offsetMinutes: number }
| { ok: false; failure: AutoExportInstantFailure };
/**
* True when the value looks like a date-time that simply lost its offset, so
* the refusal can say "no offset" rather than the less useful "unreadable".
*/
const OFFSETLESS_INSTANT_RE =
/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?(\.\d{1,9})?$/;
/**
* Parse one `Date` / `Scheduled Date` cell.
*
* Returns the absolute instant and the offset that produced it. The offset is
* handed back rather than discarded so a caller can prove, in a test, that the
* value the file stated is the value that survived.
*/
export function parseAutoExportInstant(
raw: string | null | undefined,
): AutoExportInstantResult {
const value = (raw ?? "").trim();
if (value.length === 0) return { ok: false, failure: "absent" };
const match = OFFSET_INSTANT_RE.exec(value);
if (!match) {
return {
ok: false,
failure: OFFSETLESS_INSTANT_RE.test(value)
? "missing_offset"
: "unreadable",
};
}
const [, y, mo, d, h, mi, s, zulu, sign, offH, offM] = match;
const year = Number(y);
const month = Number(mo);
const day = Number(d);
const hour = Number(h);
const minute = Number(mi);
const second = Number(s);
if (month < 1 || month > 12 || day < 1 || day > 31) {
return { ok: false, failure: "out_of_range" };
}
if (hour > 23 || minute > 59 || second > 59) {
return { ok: false, failure: "out_of_range" };
}
let offsetMinutes = 0;
if (!zulu) {
const offsetHours = Number(offH);
const offsetRemainder = Number(offM);
if (offsetRemainder > 59) return { ok: false, failure: "out_of_range" };
offsetMinutes =
(sign === "-" ? -1 : 1) * (offsetHours * 60 + offsetRemainder);
if (Math.abs(offsetMinutes) > MAX_OFFSET_MINUTES) {
return { ok: false, failure: "out_of_range" };
}
}
// `Date.UTC` rolls a nonexistent calendar day over (Feb 30 becomes Mar 2),
// so the components are read back and compared. A rolled-over value means
// the file named a day that does not exist, which is a refusal, not a
// silently shifted dose.
const wallClockMs = Date.UTC(year, month - 1, day, hour, minute, second);
const probe = new Date(wallClockMs);
if (
probe.getUTCFullYear() !== year ||
probe.getUTCMonth() !== month - 1 ||
probe.getUTCDate() !== day
) {
return { ok: false, failure: "out_of_range" };
}
const instant = new Date(wallClockMs - offsetMinutes * 60_000);
if (Number.isNaN(instant.getTime())) {
return { ok: false, failure: "out_of_range" };
}
return { ok: true, instant, offsetMinutes };
}
@@ -0,0 +1,452 @@
/**
* The medication dose-history export, from file text to per-row facts.
*
* Pure and synchronous: no database, no clock beyond an injectable `now`.
*
* Every row is either a dose or a named refusal. There is no third outcome, and
* nothing is dropped between the two: a test asserts that
* `doses.length + refusals.length` equals the number of data rows read.
*
* Only the CSV shape carries a dose history. The documented JSON shape is read
* too, and refused, for the reason set out in {@link parseAutoExportJson} — a
* refusal that says why is worth more than a lossy import.
*/
import {
ENTRY_INSTANT_FAR_PAST,
isPlausibleEntryInstant,
} from "@/lib/validations/entry-instant";
import { splitCsvRows } from "@/lib/import/csv-measurements";
import {
AUTO_EXPORT_CSV_COLUMNS,
formatAutoExportDose,
isActionableAutoExportStatus,
isAutoExportStatus,
} from "./auto-export-format";
import {
parseAutoExportInstant,
type AutoExportInstantFailure,
} from "./auto-export-instant";
/** One dose the file states, in HealthLog's own terms. */
export interface AutoExportDose {
/** 1-based source line, so a refusal can be pointed at. */
line: number;
/** The name as written, kept verbatim for the unmatched-medication report. */
medicationName: string;
/** The export's optional second name for the same medication. */
nickname: string | null;
/**
* The slot this dose belongs to. The file's `Scheduled Date` when it states
* one; otherwise the actual instant, which is how HealthLog already anchors
* an ad-hoc dose that belongs to no slot.
*/
scheduledFor: Date;
/** When the dose was actually taken. `null` for a deliberate skip. */
takenAt: Date | null;
skipped: boolean;
/** Per-dose override, set only where the file records a real deviation. */
doseTaken: string | null;
/** The UTC offset the file stated for the actual instant, in minutes. */
offsetMinutes: number;
/** True when the row came from a medication archived in the source app. */
fromArchivedMedication: boolean;
/** True when the file named no slot, so this dose is ad-hoc. */
adHoc: boolean;
}
/**
* Why a row did not become a dose. Every one of these is surfaced with a count;
* none of them is a silent drop.
*/
export const AUTO_EXPORT_REFUSAL_REASONS = [
/** Status names no decision about the dose (`Not Interacted`, `Snoozed`, …). */
"status_not_recorded",
/** Status is not a value the format documents at all. */
"status_unknown",
/** `Date` is blank — there is no dose without an instant. */
"missing_timestamp",
/** A timestamp shaped right but carrying no UTC offset. */
"missing_timezone_offset",
/** A timestamp that does not match the documented shape. */
"unreadable_timestamp",
/** A timestamp naming no real instant, or one outside plausible capture. */
"implausible_timestamp",
/** `Medication` and `Nickname` both blank — the row names no medication. */
"missing_medication",
/** A dosage cell that is not a number. */
"unreadable_dosage",
/** A CSV line with fewer cells than the header declared. */
"unreadable_row",
] as const;
export type AutoExportRefusalReason =
(typeof AUTO_EXPORT_REFUSAL_REASONS)[number];
export interface AutoExportRefusal {
line: number;
reason: AutoExportRefusalReason;
}
/** A file-level problem: nothing can be read at all. */
export const AUTO_EXPORT_FATAL_REASONS = [
"empty_file",
"missing_required_columns",
"unreadable_json",
"json_not_an_array",
/** The JSON shape records no time a dose was actually taken. */
"json_carries_no_intake_time",
] as const;
export type AutoExportFatalReason = (typeof AUTO_EXPORT_FATAL_REASONS)[number];
export interface AutoExportParseOutcome {
fatal?: { reason: AutoExportFatalReason; detail?: string };
doses: AutoExportDose[];
refusals: AutoExportRefusal[];
/** Data rows read, refusals included. `doses + refusals` must equal it. */
rowsRead: number;
/**
* Header cells the importer has no verdict for. Named rather than dropped: a
* column nobody mentions is the defect this format work exists to remove.
*/
unknownColumns: string[];
/**
* Rows whose `Codings` cell held something. Counted and left unread — the CSV
* shape gives that column no documented encoding, and guessing one would be
* the importer inventing a medication identity.
*/
csvCodingsIgnored: number;
/** Rows that came from a medication the source app has archived. */
fromArchivedMedications: number;
}
/**
* The past floor for an imported dose.
*
* HealthLog's own intake surfaces clamp a backdated dose to five years, which
* would refuse most of a real export — the reported file starts in 2023 and a
* longer history is ordinary. Importing years of history is the entire point of
* this route, so the floor is the absolute 1900 bound the shared validator
* carries and only the future side stays tight.
*/
const IMPORT_PAST_FLOOR_MS = Date.now() - ENTRY_INSTANT_FAR_PAST.getTime();
function refusalForInstantFailure(
failure: AutoExportInstantFailure,
): AutoExportRefusalReason {
switch (failure) {
case "absent":
return "missing_timestamp";
case "missing_offset":
return "missing_timezone_offset";
case "out_of_range":
return "implausible_timestamp";
case "unreadable":
return "unreadable_timestamp";
}
}
/** `Yes` / `true` / `1`; anything else, blank included, reads as not archived. */
function readArchivedFlag(raw: string | null | undefined): boolean {
const value = (raw ?? "").trim().toLowerCase();
return value === "yes" || value === "true" || value === "1";
}
type DosageRead =
| { ok: true; amount: number | null }
| { ok: false; reason: "unreadable_dosage" };
/** A blank dosage cell states nothing; a non-numeric one is a refusal. */
function readDosage(raw: string): DosageRead {
const value = raw.trim();
if (value.length === 0) return { ok: true, amount: null };
if (!/^-?\d+(\.\d+)?$/.test(value)) {
return { ok: false, reason: "unreadable_dosage" };
}
const amount = Number(value);
return Number.isFinite(amount)
? { ok: true, amount }
: { ok: false, reason: "unreadable_dosage" };
}
/**
* The per-dose override, or `null`.
*
* Written only where the file records an actual amount that DIFFERS from the
* amount the schedule expected — the only case where the dose taken is a fact
* the schedule does not already carry. `doseTaken` is documented as "NULL = the
* configured medication / schedule dose applies", so restating an unremarkable
* amount on every one of three thousand rows would be noise dressed as
* information. Where the file states no scheduled amount there is nothing to
* compare against, and absence stays absence.
*/
function resolveDoseOverride(
actual: number | null,
scheduled: number | null,
unit: string,
): string | null {
if (actual === null || scheduled === null) return null;
if (actual === scheduled) return null;
return formatAutoExportDose(actual, unit);
}
interface RowInput {
line: number;
actual: string;
scheduled: string;
medication: string;
nickname: string;
dosage: string;
scheduledDosage: string;
unit: string;
status: string;
archived: string;
}
type RowOutcome =
| { ok: true; dose: AutoExportDose }
| { ok: false; reason: AutoExportRefusalReason };
/** The one place a row becomes a dose. */
function readRow(input: RowInput, now: number): RowOutcome {
const status = input.status.trim();
if (!isAutoExportStatus(status)) {
return { ok: false, reason: "status_unknown" };
}
if (!isActionableAutoExportStatus(status)) {
return { ok: false, reason: "status_not_recorded" };
}
const medicationName = input.medication.trim();
const nickname = input.nickname.trim();
if (medicationName.length === 0 && nickname.length === 0) {
return { ok: false, reason: "missing_medication" };
}
const actual = parseAutoExportInstant(input.actual);
if (!actual.ok) {
return { ok: false, reason: refusalForInstantFailure(actual.failure) };
}
// A blank `Scheduled Date` is not a defect: the export writes it for a dose
// logged outside any slot. HealthLog anchors exactly that case with
// `scheduledFor === takenAt`, so the absence maps onto an existing notion
// instead of being invented into one.
const scheduledRead = parseAutoExportInstant(input.scheduled);
let scheduledFor: Date;
let adHoc: boolean;
if (scheduledRead.ok) {
scheduledFor = scheduledRead.instant;
adHoc = false;
} else if (scheduledRead.failure === "absent") {
scheduledFor = actual.instant;
adHoc = true;
} else {
return {
ok: false,
reason: refusalForInstantFailure(scheduledRead.failure),
};
}
for (const instant of [actual.instant, scheduledFor]) {
if (
!isPlausibleEntryInstant(instant, { now, maxAgeMs: IMPORT_PAST_FLOOR_MS })
) {
return { ok: false, reason: "implausible_timestamp" };
}
}
const dosage = readDosage(input.dosage);
if (!dosage.ok) return { ok: false, reason: dosage.reason };
const scheduledDosage = readDosage(input.scheduledDosage);
if (!scheduledDosage.ok) return { ok: false, reason: scheduledDosage.reason };
const skipped = status === "Skipped";
return {
ok: true,
dose: {
line: input.line,
medicationName: medicationName.length > 0 ? medicationName : nickname,
nickname: nickname.length > 0 ? nickname : null,
scheduledFor,
// A skip is a dose that was deliberately not taken. The file's `Date` on
// such a row is when the person pressed skip, which is not a take, so
// `takenAt` stays null exactly as every other skip path writes it.
takenAt: skipped ? null : actual.instant,
skipped,
doseTaken: skipped
? null
: resolveDoseOverride(
dosage.amount,
scheduledDosage.amount,
input.unit,
),
offsetMinutes: actual.offsetMinutes,
fromArchivedMedication: readArchivedFlag(input.archived),
adHoc,
},
};
}
const CSV_HEADER_LOOKUP = new Map<string, string>(
AUTO_EXPORT_CSV_COLUMNS.map((column) => [column.toLowerCase(), column]),
);
/** `Date`, `Medication` and `Status` are the file's irreducible content. */
const CSV_REQUIRED_COLUMNS = ["Date", "Medication", "Status"] as const;
export interface AutoExportParseOptions {
/** Clock anchor for the plausibility bound. Injectable for tests. */
now?: number;
}
const EMPTY_OUTCOME: Omit<AutoExportParseOutcome, "fatal"> = {
doses: [],
refusals: [],
rowsRead: 0,
unknownColumns: [],
csvCodingsIgnored: 0,
fromArchivedMedications: 0,
};
/** Parse the ten-column CSV export. */
export function parseAutoExportCsv(
text: string,
options: AutoExportParseOptions = {},
): AutoExportParseOutcome {
const now = options.now ?? Date.now();
const rows = splitCsvRows(text);
if (rows.length === 0) {
return { ...EMPTY_OUTCOME, fatal: { reason: "empty_file" } };
}
const header = rows[0];
const index = new Map<string, number>();
const unknownColumns: string[] = [];
header.forEach((cell, position) => {
const trimmed = cell.trim();
const canonical = CSV_HEADER_LOOKUP.get(trimmed.toLowerCase());
if (canonical) {
if (!index.has(canonical)) index.set(canonical, position);
return;
}
if (trimmed.length > 0) unknownColumns.push(trimmed);
});
// A slimmer export missing any of the other seven columns still imports, with
// the facts those columns carry honestly absent.
const missing = CSV_REQUIRED_COLUMNS.filter((column) => !index.has(column));
if (missing.length > 0) {
return {
...EMPTY_OUTCOME,
unknownColumns,
fatal: {
reason: "missing_required_columns",
detail: missing.join(", "),
},
};
}
const cellAt = (row: string[], column: string): string => {
const position = index.get(column);
if (position === undefined) return "";
return row[position] ?? "";
};
const doses: AutoExportDose[] = [];
const refusals: AutoExportRefusal[] = [];
let csvCodingsIgnored = 0;
let fromArchivedMedications = 0;
for (let i = 1; i < rows.length; i += 1) {
const row = rows[i];
const line = i + 1;
// A row shorter than the header has lost cells to a malformed quote or a
// hand edit, so the alignment is no longer trustworthy and no cell of it
// can be read at face value.
if (row.length < header.length) {
refusals.push({ line, reason: "unreadable_row" });
continue;
}
if (cellAt(row, "Codings").trim().length > 0) csvCodingsIgnored += 1;
if (readArchivedFlag(cellAt(row, "Archived"))) fromArchivedMedications += 1;
const outcome = readRow(
{
line,
actual: cellAt(row, "Date"),
scheduled: cellAt(row, "Scheduled Date"),
medication: cellAt(row, "Medication"),
nickname: cellAt(row, "Nickname"),
dosage: cellAt(row, "Dosage"),
scheduledDosage: cellAt(row, "Scheduled Dosage"),
unit: cellAt(row, "Unit"),
status: cellAt(row, "Status"),
archived: cellAt(row, "Archived"),
},
now,
);
if (outcome.ok) doses.push(outcome.dose);
else refusals.push({ line, reason: outcome.reason });
}
return {
doses,
refusals,
rowsRead: rows.length - 1,
unknownColumns,
csvCodingsIgnored,
fromArchivedMedications,
};
}
/**
* Read the documented JSON export — and refuse it, deliberately.
*
* The two shapes are not two encodings of the same thing. The CSV is a dose
* log: `Date` is when a dose was actually taken and `Scheduled Date` is the slot
* it belonged to, side by side. The documented JSON carries `scheduledDate`,
* `status`, `dosage`, and the parent medication's `start` / `end` / `form` — and
* no field for the time a dose was actually taken.
*
* So a `Taken` dose from the JSON shape could only be written by putting the
* scheduled time in `takenAt`, which asserts every dose was taken to the minute
* it was due. That is the exact flattening this whole path exists to undo: it
* manufactures a spotless on-time history out of a file that never claimed one,
* and no later correction can recover what was overwritten. A refusal that names
* the missing field leaves the person able to export the CSV, which has it.
*
* The shape is still parsed far enough to tell a JSON medication export apart
* from unreadable text, so the answer can be that sentence rather than a
* generic failure.
*/
export function parseAutoExportJson(text: string): AutoExportParseOutcome {
let decoded: unknown;
try {
decoded = JSON.parse(text);
} catch {
return { ...EMPTY_OUTCOME, fatal: { reason: "unreadable_json" } };
}
// The export wraps the array under a key in some of its output modes; both
// the bare array and a single-key envelope are recognised.
let list: unknown = decoded;
if (!Array.isArray(list) && typeof list === "object" && list !== null) {
const nested = Object.values(list as Record<string, unknown>).find(
(value) => Array.isArray(value),
);
if (nested) list = nested;
}
if (!Array.isArray(list)) {
return { ...EMPTY_OUTCOME, fatal: { reason: "json_not_an_array" } };
}
if (list.length === 0) {
return { ...EMPTY_OUTCOME, fatal: { reason: "empty_file" } };
}
return {
...EMPTY_OUTCOME,
rowsRead: list.length,
fatal: { reason: "json_carries_no_intake_time" },
};
}
@@ -0,0 +1,215 @@
/**
* From parsed doses to the queued import — medication matching and the payload.
*
* Pure: the caller passes the medications the account already holds, so the
* whole matching decision is testable without a database and the route stays a
* thin shell over it.
*
* The one policy decision that lives here is that a medication is never created
* from an import file. See {@link planAutoExportImport}.
*/
import type {
MedicationImportEntry,
MedicationImportSkipCounts,
MedicationImportSkipReason,
} from "@/lib/jobs/medication-intake-import";
import { normaliseMedicationName } from "./auto-export-format";
import type {
AutoExportDose,
AutoExportParseOutcome,
} from "./auto-export-parse";
/** A medication already on the record, as the matcher needs to see it. */
export interface MedicationMatchCandidate {
id: string;
name: string;
/**
* Non-null marks the medication a read-only mirror of an external list (the
* Apple Health medications sync). Such a row is source-exclusive by policy —
* only dose events from that same source attach to it — so an import cannot
* write to it.
*/
externalSource: string | null;
}
export interface AutoExportPlan {
/** What the worker will attempt to write, in file order. */
entries: MedicationImportEntry[];
/**
* Rows refused before the queue, by reason. Seeded into the job's progress so
* the final count the person reads covers the whole file and not just the part
* that reached the database.
*/
skippedByReason: MedicationImportSkipCounts;
/**
* Names the file used that match nothing on the record, deduplicated and in
* first-seen order. Surfaced verbatim so the person can rename or add the
* medication and re-run, rather than being told a number.
*/
unmatchedMedications: string[];
/** Names that match more than one medication, so no single one can be meant. */
ambiguousMedications: string[];
/** Names that match only a medication mirrored from an external list. */
mirroredMedications: string[];
/** Data rows read from the file, refusals included. */
rowsRead: number;
/** Rows the file marked as belonging to a medication archived in the source. */
fromArchivedMedications: number;
/** Rows whose `Codings` cell held something that was left unread. */
csvCodingsIgnored: number;
/** Header cells the importer has no verdict for. */
unknownColumns: string[];
}
/**
* An imported dose is identified by its medication and its slot instant.
*
* That is the grain the database already enforces through the live-row unique on
* `(user_id, medication_id, scheduled_for, source)`, so the replay key is a
* function of exactly the same facts. It was previously built from an optional
* counter field in the request body, which collapsed a month of distinct doses
* onto one key as soon as an export put something non-unique there.
*/
export function autoExportIdempotencyKey(
medicationId: string,
scheduledFor: Date,
): string {
return `import-${medicationId}-${scheduledFor.getTime()}`;
}
function bump(
counts: MedicationImportSkipCounts,
reason: MedicationImportSkipReason,
): void {
counts[reason] = (counts[reason] ?? 0) + 1;
}
/** Why a row's medication could not be resolved to one on the record. */
type MatchFailure =
"medication_not_found" | "medication_ambiguous" | "medication_is_mirrored";
/**
* Resolve every parsed dose against the account's medications and build the
* queue payload.
*
* **A medication is never created from an import file.** The argument for
* creating them is real: a 3,000-row export names its medications, and refusing
* every row of an unknown one makes the person do setup work by hand before the
* import is worth anything. The argument against wins anyway. An import file
* carries a display name and nothing else — no dose, no unit, no schedule, no
* form, no delivery route. A medication conjured from it would be a name with a
* blank dose that every schedule-aware surface then has to tolerate: compliance
* has no slots to expect, the inventory has no units per dose, reminders have
* nothing to fire on. And a name that differs by a word from one already on the
* record would silently fork the history in two, which is worse than a refusal,
* because a refusal is visible and a fork is not. The unmatched names are
* reported verbatim instead, so the person adds the medication properly once and
* re-runs; the import is idempotent, so re-running costs nothing.
*
* A name matching two medications is refused rather than resolved to whichever
* the database returned first.
*/
export function planAutoExportImport(
parsed: AutoExportParseOutcome,
medications: readonly MedicationMatchCandidate[],
): AutoExportPlan {
const skippedByReason: MedicationImportSkipCounts = {};
for (const refusal of parsed.refusals) bump(skippedByReason, refusal.reason);
const byName = new Map<string, MedicationMatchCandidate[]>();
for (const medication of medications) {
const key = normaliseMedicationName(medication.name);
if (key.length === 0) continue;
const bucket = byName.get(key);
if (bucket) bucket.push(medication);
else byName.set(key, [medication]);
}
const named: Record<MatchFailure, string[]> = {
medication_not_found: [],
medication_ambiguous: [],
medication_is_mirrored: [],
};
const namedSeen: Record<MatchFailure, Set<string>> = {
medication_not_found: new Set(),
medication_ambiguous: new Set(),
medication_is_mirrored: new Set(),
};
const entries: MedicationImportEntry[] = [];
const seenKeys = new Set<string>();
const resolve = (
dose: AutoExportDose,
): { ok: true; id: string } | { ok: false; reason: MatchFailure } => {
// The export can name the same medication twice: `Medication` is the display
// name and `Nickname` is the person's own label for it. Either may be the one
// that matches what the record calls it, so both are tried.
const candidates = [dose.medicationName, dose.nickname]
.filter((value): value is string => !!value && value.trim().length > 0)
.map(normaliseMedicationName)
.filter((value) => value.length > 0);
for (const key of candidates) {
const bucket = byName.get(key);
if (!bucket) continue;
if (bucket.length > 1) {
return { ok: false, reason: "medication_ambiguous" };
}
// A mirrored medication is a read-only copy of another app's list and only
// that app's dose events attach to it. Writing an imported dose onto it
// would put a row the mirror never asserted inside the mirror.
if (bucket[0].externalSource !== null) {
return { ok: false, reason: "medication_is_mirrored" };
}
return { ok: true, id: bucket[0].id };
}
return { ok: false, reason: "medication_not_found" };
};
for (const dose of parsed.doses) {
const match = resolve(dose);
if (!match.ok) {
bump(skippedByReason, match.reason);
const label = dose.medicationName;
if (!namedSeen[match.reason].has(label)) {
namedSeen[match.reason].add(label);
named[match.reason].push(label);
}
continue;
}
const idempotencyKey = autoExportIdempotencyKey(
match.id,
dose.scheduledFor,
);
// Two rows of one file resolving to the same medication and slot: only one
// can exist, and saying so here — where the whole file is in hand — counts
// it exactly, instead of leaving the second row to surface later as though
// the record had already held that dose.
if (seenKeys.has(idempotencyKey)) {
bump(skippedByReason, "duplicate_in_file");
continue;
}
seenKeys.add(idempotencyKey);
entries.push({
medicationId: match.id,
scheduledFor: dose.scheduledFor.toISOString(),
takenAt: dose.takenAt === null ? null : dose.takenAt.toISOString(),
idempotencyKey,
...(dose.doseTaken === null ? {} : { doseTaken: dose.doseTaken }),
});
}
return {
entries,
skippedByReason,
unmatchedMedications: named.medication_not_found,
ambiguousMedications: named.medication_ambiguous,
mirroredMedications: named.medication_is_mirrored,
rowsRead: parsed.rowsRead,
fromArchivedMedications: parsed.fromArchivedMedications,
csvCodingsIgnored: parsed.csvCodingsIgnored,
unknownColumns: parsed.unknownColumns,
};
}
@@ -0,0 +1,55 @@
/**
* The one projection of an intake-import job onto the polling response.
*
* Two routes read it — the per-medication import under `/api/medications/[id]/…`
* and the account-wide export import — and the client polls both through the
* same helper. Restating the field list per route is how one of them ends up
* omitting `failureReason` and a failed run reads as a run still going.
*/
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
export interface MedicationIntakeImportJobStatus {
jobId: string;
status: string;
progress: Prisma.JsonValue;
result: Prisma.JsonValue | null;
failureReason: string | null;
createdAt: string;
startedAt: string | null;
completedAt: string | null;
}
/**
* Read one job the caller owns, or `null`.
*
* `medicationId` narrows to a job scoped to that medication; omit it for the
* account-wide import. `userId` is always in the `where`, so a job id belonging
* to somebody else is a 404 and not a leak.
*/
export async function readMedicationIntakeImportJob(
userId: string,
jobId: string,
medicationId?: string,
): Promise<MedicationIntakeImportJobStatus | null> {
const row = await prisma.medicationIntakeImportJob.findFirst({
where: {
id: jobId,
userId,
// An account-wide job carries no medication, and asking for one by id must
// not resolve to the other kind. `null` is the explicit narrowing.
medicationId: medicationId ?? null,
},
});
if (!row) return null;
return {
jobId: row.id,
status: row.status,
progress: row.progress,
result: row.result,
failureReason: row.failureReason,
createdAt: row.createdAt.toISOString(),
startedAt: row.startedAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
};
}
@@ -13,6 +13,7 @@ export interface MedicationImportRow {
}
export interface MedicationImportQueueEntry {
scheduledFor: string;
takenAt: string;
idempotencyKey: string;
}
@@ -35,6 +36,11 @@ export function buildMedicationImportEntries(
return rows.map((row) => {
const takenAt = new Date(`${row.datum}T${row.uhrzeit}`);
return {
// This body carries one time per row and nothing to say which slot it
// belonged to, so every dose it describes is ad-hoc: anchored on itself,
// which is how HealthLog already records a dose that belongs to no slot.
// The export-reading route is the one that has both facts to give.
scheduledFor: takenAt.toISOString(),
takenAt: takenAt.toISOString(),
idempotencyKey: `import-${medicationId}-${takenAt.getTime()}`,
};
@@ -61,18 +61,22 @@ describe("medication intake import job — concurrent retry", () => {
const payload: MedicationImportPayload = {
entries: [
{
scheduledFor: "2026-07-20T05:00:00.000Z",
takenAt: "2026-07-20T05:00:00.000Z",
idempotencyKey: `import-${medication.id}-1`,
},
{
scheduledFor: "2026-07-20T05:00:00.000Z",
takenAt: "2026-07-20T05:00:00.000Z",
idempotencyKey: `import-${medication.id}-1`,
},
{
scheduledFor: "2026-07-20T17:00:00.000Z",
takenAt: "2026-07-20T17:00:00.000Z",
idempotencyKey: `import-${medication.id}-2`,
},
{
scheduledFor: "2026-07-21T05:00:00.000Z",
takenAt: "2026-07-21T05:00:00.000Z",
idempotencyKey: `import-${medication.id}-3`,
},
@@ -192,6 +196,7 @@ describe("medication intake import job — concurrent retry", () => {
const payload: MedicationImportPayload = {
entries: [
{
scheduledFor: "2026-07-20T05:00:00.000Z",
takenAt: "2026-07-20T05:00:00.000Z",
idempotencyKey: `import-${medication.id}-final`,
},