diff --git a/prisma/migrations/0276_account_scoped_intake_import/migration.sql b/prisma/migrations/0276_account_scoped_intake_import/migration.sql new file mode 100644 index 000000000..845bdaaef --- /dev/null +++ b/prisma/migrations/0276_account_scoped_intake_import/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1139d4d90..3cb06cee8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/app/api/medications/[id]/intake/import/[jobId]/status/route.ts b/src/app/api/medications/[id]/intake/import/[jobId]/status/route.ts index 0f8e0a4a0..cddd97cf4 100644 --- a/src/app/api/medications/[id]/intake/import/[jobId]/status/route.ts +++ b/src/app/api/medications/[id]/intake/import/[jobId]/status/route.ts @@ -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); }, ); diff --git a/src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts b/src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts new file mode 100644 index 000000000..35236eb3a --- /dev/null +++ b/src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts @@ -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); + }, +); diff --git a/src/app/api/medications/intake/dose-history-import/route.ts b/src/app/api/medications/intake/dose-history-import/route.ts new file mode 100644 index 000000000..df4150bd6 --- /dev/null +++ b/src/app/api/medications/intake/dose-history-import/route.ts @@ -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, + ); +}); diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index 1cc758146..5866b1f65 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -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, userId: string, - medicationId: string, + jobMedicationId: string | null, timezone: string, days: readonly string[], ): Promise { 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` diff --git a/src/lib/medications/import/auto-export-format.ts b/src/lib/medications/import/auto-export-format.ts new file mode 100644 index 000000000..2cfbf4cff --- /dev/null +++ b/src/lib/medications/import/auto-export-format.ts @@ -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(); +} diff --git a/src/lib/medications/import/auto-export-instant.ts b/src/lib/medications/import/auto-export-instant.ts new file mode 100644 index 000000000..767bda7d9 --- /dev/null +++ b/src/lib/medications/import/auto-export-instant.ts @@ -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 }; +} diff --git a/src/lib/medications/import/auto-export-parse.ts b/src/lib/medications/import/auto-export-parse.ts new file mode 100644 index 000000000..99d55f5e2 --- /dev/null +++ b/src/lib/medications/import/auto-export-parse.ts @@ -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( + 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 = { + 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(); + 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).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" }, + }; +} diff --git a/src/lib/medications/import/auto-export-plan.ts b/src/lib/medications/import/auto-export-plan.ts new file mode 100644 index 000000000..5ca81b81c --- /dev/null +++ b/src/lib/medications/import/auto-export-plan.ts @@ -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(); + 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 = { + medication_not_found: [], + medication_ambiguous: [], + medication_is_mirrored: [], + }; + const namedSeen: Record> = { + medication_not_found: new Set(), + medication_ambiguous: new Set(), + medication_is_mirrored: new Set(), + }; + const entries: MedicationImportEntry[] = []; + const seenKeys = new Set(); + + 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, + }; +} diff --git a/src/lib/medications/intake-import-job-status.ts b/src/lib/medications/intake-import-job-status.ts new file mode 100644 index 000000000..54e01d738 --- /dev/null +++ b/src/lib/medications/intake-import-job-status.ts @@ -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 { + 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, + }; +} diff --git a/src/lib/medications/intake-import-payload.ts b/src/lib/medications/intake-import-payload.ts index 27d085df5..ded1f0720 100644 --- a/src/lib/medications/intake-import-payload.ts +++ b/src/lib/medications/intake-import-payload.ts @@ -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()}`, }; diff --git a/tests/integration/medication-intake-import-job.test.ts b/tests/integration/medication-intake-import-job.test.ts index 645975b07..f5847ffde 100644 --- a/tests/integration/medication-intake-import-job.test.ts +++ b/tests/integration/medication-intake-import-job.test.ts @@ -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`, },