From e984ada5f1aca6565c637fcf3517e73a0370b02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 10:16:07 +0200 Subject: [PATCH] feat(cycle): import Apple Health reproductive data into cycle, add cycle export Promote the reproductive HealthKit category types out of the Apple-Health-import privacy-hold list and route them into cycle day-logs instead of measurements, and surface cycle data on the three export surfaces. Import promotion: - A new HealthKit -> cycle mapping module is the single source of truth for how MenstrualFlow, IntermenstrualBleeding, CervicalMucusQuality, OvulationTestResult, SexualActivity, PregnancyTestResult, ProgesteroneTestResult, Contraceptive and the symptom category types fold into CycleDayLog fields / seeded symptom keys. The XML importer accumulates same-day reproductive samples and writes one canonical day-log per day with source APPLE_HEALTH and a synthetic per-day externalId for idempotent re-import. The fold is gated on cycle tracking being enabled, so a non-cycle export never provisions cycle rows. A contraceptive method nudges the profile goal toward avoiding pregnancy only when the goal is still on its default. Basal body temperature stays a measurement and mirrors onto the day-log. Pregnancy/Lactation status and the pregnancy-bleeding types stay deferred; the cycle awareness types remain server-derived. Export: - FHIR emits LMP (LOINC 8665-2), average cycle length (64700-8), period length (64698-4) and the current phase as opt-in reproductive-health observations grouped into their own document section. - The doctor PDF gains an opt-in cycle section (recent cycles, average length and variability, current phase), privacy-default off like mood. - The full-backup export, the weekly backup worker and the off-host DR snapshot include the cycle tables, and the admin restore re-creates them; the day-log note round-trips as ciphertext and is never decrypted onto a report surface. Mapping table documented in docs/api/cycle-healthkit-mapping.md. Adds unit + integration coverage for the mapping, the import routing, the FHIR observations and the backup round-trip. OpenAPI regenerated. Six-locale strings added. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/cycle-healthkit-mapping.md | 71 ++++ docs/api/openapi.yaml | 4 + messages/de.json | 18 +- messages/en.json | 18 +- messages/es.json | 18 +- messages/fr.json | 18 +- messages/it.json | 18 +- messages/pl.json | 18 +- .../api/admin/backups/[id]/restore/route.ts | 15 + src/app/api/admin/backups/upload/route.ts | 2 + .../__tests__/route.test.ts | 2 + .../export/__tests__/per-type-routes.test.ts | 7 + src/app/api/export/full-backup/route.ts | 14 +- src/components/admin/backups-section.tsx | 6 +- .../doctor-report/doctor-report-dialog.tsx | 8 + .../cycle/__tests__/healthkit-mapping.test.ts | 174 +++++++++ src/lib/cycle/backup.ts | 310 +++++++++++++++ src/lib/cycle/export-data.ts | 146 +++++++ src/lib/cycle/healthkit-mapping.ts | 355 ++++++++++++++++++ src/lib/cycle/import-accumulator.ts | 243 ++++++++++++ src/lib/doctor-report-data.ts | 25 ++ src/lib/doctor-report-pdf-core.ts | 92 +++++ .../fhir/__tests__/cycle-observations.test.ts | 106 ++++++ src/lib/fhir/build-bundle.ts | 21 ++ src/lib/fhir/loinc-map.ts | 8 + src/lib/fhir/resources.ts | 142 +++++++ src/lib/fhir/types.ts | 3 + src/lib/jobs/__tests__/offhost-backup.test.ts | 6 + src/lib/jobs/offhost-backup.ts | 45 ++- src/lib/jobs/reminder-worker.ts | 9 +- .../__tests__/apple-health-mapping.test.ts | 32 +- src/lib/measurements/apple-health-mapping.ts | 29 +- .../import-apple-health-export.ts | 86 ++++- src/lib/validations/__tests__/backup.test.ts | 64 ++++ .../__tests__/doctor-report-prefs.test.ts | 1 + src/lib/validations/backup.ts | 74 ++++ src/lib/validations/doctor-report-prefs.ts | 5 + src/lib/validations/health-record-export.ts | 4 + tests/integration/cycle-import-export.test.ts | 230 ++++++++++++ 39 files changed, 2406 insertions(+), 41 deletions(-) create mode 100644 docs/api/cycle-healthkit-mapping.md create mode 100644 src/lib/cycle/__tests__/healthkit-mapping.test.ts create mode 100644 src/lib/cycle/backup.ts create mode 100644 src/lib/cycle/export-data.ts create mode 100644 src/lib/cycle/healthkit-mapping.ts create mode 100644 src/lib/cycle/import-accumulator.ts create mode 100644 src/lib/fhir/__tests__/cycle-observations.test.ts create mode 100644 tests/integration/cycle-import-export.test.ts diff --git a/docs/api/cycle-healthkit-mapping.md b/docs/api/cycle-healthkit-mapping.md new file mode 100644 index 000000000..69bce5e91 --- /dev/null +++ b/docs/api/cycle-healthkit-mapping.md @@ -0,0 +1,71 @@ +# Apple Health → cycle mapping + +How HealthLog folds Apple Health reproductive samples into the cycle model. +Both the `export.xml` importer and the iOS batch contract share one mapping +table: `src/lib/cycle/healthkit-mapping.ts`. This page is the human-readable +companion — keep the two in sync. + +Reproductive samples route into **cycle day-logs** (`CycleDayLog`), never into +`Measurement`. Each Apple Health day collapses into one canonical day-log per +`(user, date)`; the importer writes `source = APPLE_HEALTH` with a synthetic +per-day `externalId` (`hkcycle:`) so a re-import upserts the same +row. The fold only runs when cycle tracking is enabled for the account. + +## Day-log fields + +| HealthKit category type | CycleDayLog field | Values | +|---|---|---| +| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE | +| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` | +| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE | +| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE | +| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` | +| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE | +| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE | +| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY | + +HealthLog's `SPOTTING` flow level has no HealthKit counterpart; it is only ever +produced by a manual entry. HealthKit `unspecified` flow maps to `LIGHT` (a +logged-but-unspecified bleeding day is at least light flow). + +## Symptom category types → seeded symptom keys + +Symptom category samples link to the seeded `CycleSymptom` catalogue by key. A +sample whose severity is *not present* (codepoint 1) does NOT create a link. + +| HealthKit category type | CycleSymptom key | +|---|---| +| `AbdominalCramps` | `cramps` | +| `Headache` | `headache` | +| `Bloating` | `bloating` | +| `Acne` | `acne` | +| `BreastPain` | `breast_tenderness` | +| `Fatigue` | `fatigue` | +| `LowerBackPain` | `back_pain` | +| `SleepChanges` | `insomnia` | +| `MoodChanges` | `mood_swings` | +| `AppetiteChanges` | `food_cravings` | +| `Nausea` | `nausea` | + +## Profile-level signals + +A `Contraceptive` sample asserting an active method also nudges the +`CycleProfile.goal` to `AVOID_PREGNANCY` — but only when the goal is still on +its `GENERAL_HEALTH` default, so an explicit user choice is never overwritten. + +## Basal body temperature + +`basalBodyTemperature` is **not** routed here. It stays a +`Measurement(BODY_TEMPERATURE)` (the system of record for the rollup tier and +the cross-source source-priority ladder) and is mirrored onto +`CycleDayLog.basalBodyTempC` for the cycle chart. Apple Watch wrist/skin +temperature already flows to `WRIST_TEMPERATURE` / `SKIN_TEMPERATURE` and feeds +the temperature-trend ovulation method directly from `Measurement`. + +## Deferred + +| HealthKit category type | Status | +|---|---| +| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). | +| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. | +| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. | diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 53bb940db..77494704f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -829,6 +829,8 @@ paths: type: boolean bmi: type: boolean + cycle: + type: boolean locale: type: string enum: @@ -955,6 +957,8 @@ paths: type: boolean bmi: type: boolean + cycle: + type: boolean resourceTypes: maxItems: 8 type: array diff --git a/messages/de.json b/messages/de.json index 7474bcc21..a271c6a83 100644 --- a/messages/de.json +++ b/messages/de.json @@ -146,6 +146,7 @@ "moodSensitive": "Sensibel — standardmäßig aus.", "compliance": "Medikamenten-Compliance", "sleep": "Schlafdauer", + "cycle": "Menstruationszyklus", "empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.", "unavailableHint": "Keine Daten in diesem Zeitraum" }, @@ -163,7 +164,22 @@ "wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus deinen erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.", "wellnessRecovery": "Erholung", "wellnessStress": "Stress", - "wellnessStrain": "Belastung" + "wellnessStrain": "Belastung", + "cycleTitle": "Menstruationszyklus", + "cycleLmp": "Letzte Periode", + "cycleAvgLength": "Durchschnittliche Zykluslänge", + "cycleAvgPeriod": "Durchschnittliche Periodendauer", + "cyclePhase": "Aktuelle Phase", + "cycleDays": "Tage", + "cycleColStart": "Periodenbeginn", + "cycleColLength": "Zykluslänge (Tage)", + "cycleColPeriod": "Periodendauer (Tage)", + "cyclePhases": { + "MENSTRUAL": "Menstruation", + "FOLLICULAR": "Follikelphase", + "OVULATORY": "Ovulation", + "LUTEAL": "Lutealphase" + } }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", diff --git a/messages/en.json b/messages/en.json index 0dd870fa6..2a470fc3a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -146,6 +146,7 @@ "moodSensitive": "Sensitive — off by default.", "compliance": "Medication compliance", "sleep": "Sleep duration", + "cycle": "Menstrual cycle", "empty": "No data was recorded in this date range yet. The report will still include the cover page.", "unavailableHint": "No data in this period" }, @@ -163,7 +164,22 @@ "wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.", "wellnessRecovery": "Recovery", "wellnessStress": "Stress", - "wellnessStrain": "Strain" + "wellnessStrain": "Strain", + "cycleTitle": "Menstrual cycle", + "cycleLmp": "Last period", + "cycleAvgLength": "Average cycle length", + "cycleAvgPeriod": "Average period length", + "cyclePhase": "Current phase", + "cycleDays": "days", + "cycleColStart": "Period start", + "cycleColLength": "Cycle length (days)", + "cycleColPeriod": "Period length (days)", + "cyclePhases": { + "MENSTRUAL": "Menstrual", + "FOLLICULAR": "Follicular", + "OVULATORY": "Ovulatory", + "LUTEAL": "Luteal" + } }, "errorBoundary": { "title": "Something went wrong", diff --git a/messages/es.json b/messages/es.json index 642b8c297..8fecd5fc0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -146,6 +146,7 @@ "moodSensitive": "Sensible — desactivado por defecto.", "compliance": "Cumplimiento de medicación", "sleep": "Duración del sueño", + "cycle": "Ciclo menstrual", "empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada.", "unavailableHint": "Sin datos en este periodo" }, @@ -163,7 +164,22 @@ "wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.", "wellnessRecovery": "Recuperación", "wellnessStress": "Estrés", - "wellnessStrain": "Esfuerzo" + "wellnessStrain": "Esfuerzo", + "cycleTitle": "Ciclo menstrual", + "cycleLmp": "Última menstruación", + "cycleAvgLength": "Duración media del ciclo", + "cycleAvgPeriod": "Duración media del periodo", + "cyclePhase": "Fase actual", + "cycleDays": "días", + "cycleColStart": "Inicio del periodo", + "cycleColLength": "Duración del ciclo (días)", + "cycleColPeriod": "Duración del periodo (días)", + "cyclePhases": { + "MENSTRUAL": "Menstrual", + "FOLLICULAR": "Folicular", + "OVULATORY": "Ovulatoria", + "LUTEAL": "Lútea" + } }, "errorBoundary": { "title": "Algo ha salido mal", diff --git a/messages/fr.json b/messages/fr.json index 9af7ef8e2..ca24b7c24 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -146,6 +146,7 @@ "moodSensitive": "Sensible — désactivé par défaut.", "compliance": "Observance des médicaments", "sleep": "Durée du sommeil", + "cycle": "Cycle menstruel", "empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde.", "unavailableHint": "Pas de données sur cette période" }, @@ -163,7 +164,22 @@ "wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.", "wellnessRecovery": "Récupération", "wellnessStress": "Stress", - "wellnessStrain": "Effort" + "wellnessStrain": "Effort", + "cycleTitle": "Cycle menstruel", + "cycleLmp": "Dernières règles", + "cycleAvgLength": "Durée moyenne du cycle", + "cycleAvgPeriod": "Durée moyenne des règles", + "cyclePhase": "Phase actuelle", + "cycleDays": "jours", + "cycleColStart": "Début des règles", + "cycleColLength": "Durée du cycle (jours)", + "cycleColPeriod": "Durée des règles (jours)", + "cyclePhases": { + "MENSTRUAL": "Menstruelle", + "FOLLICULAR": "Folliculaire", + "OVULATORY": "Ovulatoire", + "LUTEAL": "Lutéale" + } }, "errorBoundary": { "title": "Une erreur est survenue", diff --git a/messages/it.json b/messages/it.json index f5ccfdffb..28ad42835 100644 --- a/messages/it.json +++ b/messages/it.json @@ -146,6 +146,7 @@ "moodSensitive": "Sensibile — disattivato per default.", "compliance": "Aderenza ai farmaci", "sleep": "Durata del sonno", + "cycle": "Ciclo mestruale", "empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina.", "unavailableHint": "Nessun dato in questo periodo" }, @@ -163,7 +164,22 @@ "wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.", "wellnessRecovery": "Recupero", "wellnessStress": "Stress", - "wellnessStrain": "Sforzo" + "wellnessStrain": "Sforzo", + "cycleTitle": "Ciclo mestruale", + "cycleLmp": "Ultime mestruazioni", + "cycleAvgLength": "Durata media del ciclo", + "cycleAvgPeriod": "Durata media delle mestruazioni", + "cyclePhase": "Fase attuale", + "cycleDays": "giorni", + "cycleColStart": "Inizio mestruazioni", + "cycleColLength": "Durata del ciclo (giorni)", + "cycleColPeriod": "Durata mestruazioni (giorni)", + "cyclePhases": { + "MENSTRUAL": "Mestruale", + "FOLLICULAR": "Follicolare", + "OVULATORY": "Ovulatoria", + "LUTEAL": "Luteale" + } }, "errorBoundary": { "title": "Qualcosa è andato storto", diff --git a/messages/pl.json b/messages/pl.json index 01fdf341d..0d45c451d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -146,6 +146,7 @@ "moodSensitive": "Wrażliwe — domyślnie wyłączone.", "compliance": "Przestrzeganie leków", "sleep": "Czas snu", + "cycle": "Cykl menstruacyjny", "empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową.", "unavailableHint": "Brak danych w tym okresie" }, @@ -163,7 +164,22 @@ "wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.", "wellnessRecovery": "Regeneracja", "wellnessStress": "Stres", - "wellnessStrain": "Obciążenie" + "wellnessStrain": "Obciążenie", + "cycleTitle": "Cykl menstruacyjny", + "cycleLmp": "Ostatnia miesiączka", + "cycleAvgLength": "Średnia długość cyklu", + "cycleAvgPeriod": "Średnia długość miesiączki", + "cyclePhase": "Bieżąca faza", + "cycleDays": "dni", + "cycleColStart": "Początek miesiączki", + "cycleColLength": "Długość cyklu (dni)", + "cycleColPeriod": "Długość miesiączki (dni)", + "cyclePhases": { + "MENSTRUAL": "Menstruacyjna", + "FOLLICULAR": "Folikularna", + "OVULATORY": "Owulacyjna", + "LUTEAL": "Lutealna" + } }, "errorBoundary": { "title": "Coś poszło nie tak", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 417ac5907..9c47ad1be 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -49,6 +49,7 @@ import { MEDICATION_COMPLIANCE_BACKFILL_DAYS, } from "@/lib/rollups/medication-compliance-rollups"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; +import { restoreCycleData } from "@/lib/cycle/backup"; export const dynamic = "force-dynamic"; @@ -63,6 +64,10 @@ interface RestoreResponse { notificationChannels: number; pushSubscriptions: number; telegramScheduledDeletions: number; + // v1.15.0 — cycle tables wiped before recreate. + cycles: number; + cycleDayLogs: number; + cycleProfile: number; }; } @@ -397,6 +402,11 @@ const handler = apiHandler( }); } + // v1.15.0 — cycle tables (profile + observed spans + day-logs + + // symptom links). Delete-then-recreate, mirroring the contract + // above. `notesEncrypted` is restored as ciphertext verbatim. + const cycleCleared = await restoreCycleData(tx, ownerId, payload); + return { measurements: measurements.count, medications: meds.count, @@ -405,6 +415,9 @@ const handler = apiHandler( notificationChannels: channels.count, pushSubscriptions: subs.count, telegramScheduledDeletions: tgDel.count, + cycles: cycleCleared.cycles, + cycleDayLogs: cycleCleared.cycleDayLogs, + cycleProfile: cycleCleared.cycleProfile, }; }); } catch (err) { @@ -513,6 +526,8 @@ const handler = apiHandler( medications: summary.medications, intakeEvents: summary.intakeEvents, moodEntries: summary.moodEntries, + cycles: summary.cycles, + cycleDayLogs: summary.cycleDayLogs, }, }, }); diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index ce0387f74..9457cd99d 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -216,6 +216,8 @@ export const POST = apiHandler(async (request: NextRequest) => { medications: summary.medications, intakeEvents: summary.intakeEvents, moodEntries: summary.moodEntries, + cycles: summary.cycles, + cycleDayLogs: summary.cycleDayLogs, }, }, }); diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index 10b0aea2d..85722cba5 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -90,6 +90,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { mood: true, compliance: true, sleep: false, + cycle: false, }; const res = await (PUT as (r: Request) => Promise)(mkPut(body)); expect(res.status).toBe(200); @@ -112,6 +113,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { mood: true, compliance: false, sleep: false, + cycle: false, }; vi.mocked(prisma.user.findUnique).mockResolvedValue({ doctorReportPrefsJson: current, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 141559a27..3af459473 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -16,6 +16,10 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, + // v1.15.0 — cycle tables read by the full-backup helper. + cycleProfile: { findUnique: vi.fn() }, + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, auditLog: { create: vi.fn() }, }, @@ -318,6 +322,9 @@ describe("GET /api/export/full-backup", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); const { GET } = await import("../full-backup/route"); const res = await GET(mkReq("http://localhost/api/export/full-backup")); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index 516048198..286e84282 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -22,6 +22,7 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, getClientIp } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; +import { buildCycleBackupSection } from "@/lib/cycle/backup"; import { NextRequest, NextResponse } from "next/server"; export const GET = apiHandler(async (request: NextRequest) => { @@ -33,7 +34,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiError("Maximum 10 exports per hour", 429); } - const [measurements, medications, intakeEvents, moodEntries] = + const [measurements, medications, intakeEvents, moodEntries, cycle] = await Promise.all([ prisma.measurement.findMany({ // v1.4.41 W-DELETED-2 — soft-deleted rows are excluded from the @@ -74,6 +75,9 @@ export const GET = apiHandler(async (request: NextRequest) => { where: { userId: user.id, deletedAt: null }, orderBy: { moodLoggedAt: "desc" }, }), + // v1.15.0 — cycle tables (profile + observed spans + day-logs). The + // shared helper carries `notesEncrypted` verbatim (never decrypted). + buildCycleBackupSection(prisma, user.id), ]); // Shape mirrors the pg-boss `data-backup` worker exactly so the same @@ -118,6 +122,10 @@ export const GET = apiHandler(async (request: NextRequest) => { source: e.source, loggedAt: e.moodLoggedAt.toISOString(), })), + // v1.15.0 — cycle slice (profile + observed spans + day-logs). + cycleProfile: cycle.cycleProfile, + cycles: cycle.cycles, + cycleDayLogs: cycle.cycleDayLogs, }; await auditLog("user.export.full-backup", { @@ -129,6 +137,8 @@ export const GET = apiHandler(async (request: NextRequest) => { medications: medications.length, intakeEvents: intakeEvents.length, moodEntries: moodEntries.length, + cycles: cycle.cycles.length, + cycleDayLogs: cycle.cycleDayLogs.length, }, }, }); @@ -139,6 +149,8 @@ export const GET = apiHandler(async (request: NextRequest) => { export_medications_count: medications.length, export_intake_count: intakeEvents.length, export_mood_count: moodEntries.length, + export_cycle_count: cycle.cycles.length, + export_cycle_day_log_count: cycle.cycleDayLogs.length, }, }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index b9a7d9b6d..9c82204e9 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -234,6 +234,8 @@ export function BackupsSection() { medications: number; intakeEvents: number; moodEntries: number; + cycles?: number; + cycleDayLogs?: number; }; }; }, @@ -242,7 +244,9 @@ export function BackupsSection() { data.summary.measurements + data.summary.medications + data.summary.intakeEvents + - data.summary.moodEntries; + data.summary.moodEntries + + (data.summary.cycles ?? 0) + + (data.summary.cycleDayLogs ?? 0); toast.success( t("admin.section.backups.uploadSuccess", { count: String(total) }), ); diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index ad10befec..23c7c1a40 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -80,6 +80,11 @@ interface SectionAvailability { mood: boolean; compliance: boolean; sleep: boolean; + // v1.15.0 — cycle section. Not rendered by this legacy dialog + // (SECTION_ORDER omits it); the flagship health-record export surfaces + // the cycle toggle. Present so `keyof DoctorReportPrefs` indexing + // typechecks. Optional — the availability endpoint may not provide it. + cycle?: boolean; } export interface DoctorReportSubmitPayload { @@ -541,6 +546,9 @@ const SECTION_LABEL_KEYS: Record = { mood: "doctorReport.sections.mood", compliance: "doctorReport.sections.compliance", sleep: "doctorReport.sections.sleep", + // v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it); + // present so the `keyof DoctorReportPrefs` record stays exhaustive. + cycle: "doctorReport.sections.cycle", }; /** diff --git a/src/lib/cycle/__tests__/healthkit-mapping.test.ts b/src/lib/cycle/__tests__/healthkit-mapping.test.ts new file mode 100644 index 000000000..10101187f --- /dev/null +++ b/src/lib/cycle/__tests__/healthkit-mapping.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import { + mapHkCycleSample, + isCycleHkIdentifier, + hkSymptomIsPresent, + HK_MENSTRUAL_FLOW, + HK_INTERMENSTRUAL_BLEEDING, + HK_CERVICAL_MUCUS, + HK_OVULATION_TEST, + HK_SEXUAL_ACTIVITY, + HK_PREGNANCY_TEST, + HK_PROGESTERONE_TEST, + HK_CONTRACEPTIVE, + HK_PREGNANCY, + HK_LACTATION, +} from "../healthkit-mapping"; + +describe("cycle/healthkit-mapping — identifier ownership", () => { + it("owns the promoted reproductive identifiers", () => { + expect(isCycleHkIdentifier(HK_MENSTRUAL_FLOW)).toBe(true); + expect(isCycleHkIdentifier(HK_OVULATION_TEST)).toBe(true); + expect(isCycleHkIdentifier(HK_CONTRACEPTIVE)).toBe(true); + expect(isCycleHkIdentifier("HKCategoryTypeIdentifierAbdominalCramps")).toBe( + true, + ); + }); + + it("does NOT own pregnancy/lactation status (deferred — no schema target)", () => { + expect(isCycleHkIdentifier(HK_PREGNANCY)).toBe(false); + expect(isCycleHkIdentifier(HK_LACTATION)).toBe(false); + }); + + it("does NOT own unrelated identifiers", () => { + expect(isCycleHkIdentifier("HKQuantityTypeIdentifierStepCount")).toBe(false); + }); +}); + +describe("cycle/healthkit-mapping — menstrual flow", () => { + it("maps the symbolic value names to FlowLevel", () => { + expect( + mapHkCycleSample(HK_MENSTRUAL_FLOW, "HKCategoryValueMenstrualFlowLight"), + ).toEqual({ kind: "day-log", fields: { flow: "LIGHT" } }); + expect( + mapHkCycleSample(HK_MENSTRUAL_FLOW, "HKCategoryValueMenstrualFlowHeavy"), + ).toEqual({ kind: "day-log", fields: { flow: "HEAVY" } }); + expect( + mapHkCycleSample(HK_MENSTRUAL_FLOW, "HKCategoryValueMenstrualFlowNone"), + ).toEqual({ kind: "day-log", fields: { flow: "NONE" } }); + }); + + it("maps the integer codepoints (live-API form)", () => { + expect(mapHkCycleSample(HK_MENSTRUAL_FLOW, "2")).toEqual({ + kind: "day-log", + fields: { flow: "LIGHT" }, + }); + expect(mapHkCycleSample(HK_MENSTRUAL_FLOW, "4")).toEqual({ + kind: "day-log", + fields: { flow: "HEAVY" }, + }); + }); + + it("maps HK unspecified → LIGHT (conservative boundary)", () => { + expect( + mapHkCycleSample( + HK_MENSTRUAL_FLOW, + "HKCategoryValueMenstrualFlowUnspecified", + ), + ).toEqual({ kind: "day-log", fields: { flow: "LIGHT" } }); + }); + + it("skips an unrecognised flow value", () => { + expect(mapHkCycleSample(HK_MENSTRUAL_FLOW, "garbage")).toEqual({ + kind: "skip", + }); + }); +}); + +describe("cycle/healthkit-mapping — ovulation + cervical mucus + tests", () => { + it("maps ovulation results", () => { + expect( + mapHkCycleSample( + HK_OVULATION_TEST, + "HKCategoryValueOvulationTestResultLuteinizingHormoneSurge", + ), + ).toEqual({ kind: "day-log", fields: { ovulationTest: "POSITIVE_LH_SURGE" } }); + expect(mapHkCycleSample(HK_OVULATION_TEST, "4")).toEqual({ + kind: "day-log", + fields: { ovulationTest: "ESTROGEN_SURGE" }, + }); + }); + + it("maps cervical mucus", () => { + expect( + mapHkCycleSample( + HK_CERVICAL_MUCUS, + "HKCategoryValueCervicalMucusQualityEggWhite", + ), + ).toEqual({ kind: "day-log", fields: { cervicalMucus: "EGG_WHITE" } }); + }); + + it("maps pregnancy + progesterone test results", () => { + expect(mapHkCycleSample(HK_PREGNANCY_TEST, "2")).toEqual({ + kind: "day-log", + fields: { pregnancyTest: "POSITIVE" }, + }); + expect(mapHkCycleSample(HK_PROGESTERONE_TEST, "1")).toEqual({ + kind: "day-log", + fields: { progesteroneTest: "NEGATIVE" }, + }); + }); + + it("maps intermenstrual bleeding (presence)", () => { + expect(mapHkCycleSample(HK_INTERMENSTRUAL_BLEEDING, undefined)).toEqual({ + kind: "day-log", + fields: { intermenstrualBleeding: true }, + }); + }); +}); + +describe("cycle/healthkit-mapping — sexual activity + protection", () => { + it("records protectedSex from the protection metadata", () => { + expect(mapHkCycleSample(HK_SEXUAL_ACTIVITY, undefined, true)).toEqual({ + kind: "day-log", + fields: { sexualActivity: true, protectedSex: true }, + }); + expect(mapHkCycleSample(HK_SEXUAL_ACTIVITY, undefined, false)).toEqual({ + kind: "day-log", + fields: { sexualActivity: true, protectedSex: false }, + }); + }); + + it("leaves protectedSex null when the metadata is absent", () => { + expect(mapHkCycleSample(HK_SEXUAL_ACTIVITY, undefined)).toEqual({ + kind: "day-log", + fields: { sexualActivity: true, protectedSex: null }, + }); + }); +}); + +describe("cycle/healthkit-mapping — contraceptive routes to day-log + profile", () => { + it("records the method AND carries a profile nudge", () => { + expect( + mapHkCycleSample( + HK_CONTRACEPTIVE, + "HKCategoryValueContraceptiveOral", + ), + ).toEqual({ + kind: "day-log+profile", + fields: { contraceptive: "ORAL" }, + profile: { contraceptive: "ORAL" }, + }); + }); +}); + +describe("cycle/healthkit-mapping — symptoms", () => { + it("maps symptom identifiers to the seeded catalogue keys", () => { + expect( + mapHkCycleSample("HKCategoryTypeIdentifierAbdominalCramps", "3"), + ).toEqual({ kind: "day-log", fields: { symptomKey: "cramps" } }); + expect( + mapHkCycleSample("HKCategoryTypeIdentifierLowerBackPain", "4"), + ).toEqual({ kind: "day-log", fields: { symptomKey: "back_pain" } }); + }); + + it("skips an explicit not-present symptom sample", () => { + expect( + mapHkCycleSample("HKCategoryTypeIdentifierHeadache", "1"), + ).toEqual({ kind: "skip" }); + expect(hkSymptomIsPresent("1")).toBe(false); + expect(hkSymptomIsPresent("HKCategoryValueSeverityNotPresent")).toBe(false); + expect(hkSymptomIsPresent("3")).toBe(true); + }); +}); diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts new file mode 100644 index 000000000..309f81742 --- /dev/null +++ b/src/lib/cycle/backup.ts @@ -0,0 +1,310 @@ +/** + * Cycle-tracking backup serialisation — the one place the full-backup + * writers (the on-demand `GET /api/export/full-backup` route AND the + * pg-boss `data-backup` worker) read the cycle tables, so the two writers + * never drift. Mirrors the `backupPayloadSchema` cycle shapes. + * + * Reads are scoped to `userId` + `deletedAt: null` and exclude predicted + * (forecast) cycle rows — only observed history round-trips. `notesEncrypted` + * is carried as the ciphertext envelope VERBATIM: the backup never decrypts + * the day-log note, so a wrong-surface plaintext leak is impossible and the + * owner's note round-trips encrypted at rest. + */ +import type { PrismaClient, Prisma } from "@/generated/prisma/client"; +import type { BackupPayload } from "@/lib/validations/backup"; + +/** The cycle slice of a backup payload. */ +export interface CycleBackupSection { + cycleProfile: { + goal: string; + cycleTrackingEnabled: boolean | null; + typicalCycleLength: number | null; + typicalPeriodLength: number | null; + lutealPhaseLength: number | null; + predictionEnabled: boolean; + rawChartMode: boolean; + discreetNotifications: boolean; + sensitiveCategoryEncryption: boolean; + } | null; + cycles: Array<{ + startDate: string; + endDate: string | null; + periodEndDate: string | null; + lengthDays: number | null; + ovulationDate: string | null; + ovulationConfirmed: boolean; + tz: string | null; + }>; + cycleDayLogs: Array<{ + date: string; + flow: string | null; + intermenstrualBleeding: boolean; + basalBodyTempC: number | null; + ovulationTest: string | null; + cervicalMucus: string | null; + sexualActivity: boolean; + protectedSex: boolean | null; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; + notesEncrypted: string | null; + source: string; + externalId: string | null; + tz: string | null; + symptomKeys: string[]; + }>; +} + +/** + * Build the cycle section of a user's full backup. Accepts any client with + * the cycle delegates (the route's global client OR the worker's local + * client) so both writers share one read. + */ +export async function buildCycleBackupSection( + prisma: Pick< + PrismaClient, + "cycleProfile" | "menstrualCycle" | "cycleDayLog" + >, + userId: string, +): Promise { + const [profile, cycles, dayLogs] = await Promise.all([ + prisma.cycleProfile.findUnique({ where: { userId } }), + prisma.menstrualCycle.findMany({ + where: { userId, deletedAt: null, isPredicted: false }, + orderBy: { startDate: "asc" }, + }), + prisma.cycleDayLog.findMany({ + where: { userId, deletedAt: null }, + orderBy: { date: "asc" }, + include: { symptomLinks: { include: { symptom: { select: { key: true } } } } }, + }), + ]); + + return { + cycleProfile: profile + ? { + goal: profile.goal, + cycleTrackingEnabled: profile.cycleTrackingEnabled, + typicalCycleLength: profile.typicalCycleLength, + typicalPeriodLength: profile.typicalPeriodLength, + lutealPhaseLength: profile.lutealPhaseLength, + predictionEnabled: profile.predictionEnabled, + rawChartMode: profile.rawChartMode, + discreetNotifications: profile.discreetNotifications, + sensitiveCategoryEncryption: profile.sensitiveCategoryEncryption, + } + : null, + cycles: cycles.map((c) => ({ + startDate: c.startDate, + endDate: c.endDate, + periodEndDate: c.periodEndDate, + lengthDays: c.lengthDays, + ovulationDate: c.ovulationDate, + ovulationConfirmed: c.ovulationConfirmed, + tz: c.tz, + })), + cycleDayLogs: dayLogs.map((d) => ({ + date: d.date, + flow: d.flow, + intermenstrualBleeding: d.intermenstrualBleeding, + basalBodyTempC: d.basalBodyTempC, + ovulationTest: d.ovulationTest, + cervicalMucus: d.cervicalMucus, + sexualActivity: d.sexualActivity, + protectedSex: d.protectedSex, + pregnancyTest: d.pregnancyTest, + progesteroneTest: d.progesteroneTest, + contraceptive: d.contraceptive, + // Ciphertext envelope carried verbatim — never decrypted here. + notesEncrypted: d.notesEncrypted, + source: d.source, + externalId: d.externalId, + tz: d.tz, + symptomKeys: d.symptomLinks.map((l) => l.symptom.key), + })), + }; +} + +/* ── restore ───────────────────────────────────────────────────────── */ + +/** Counts the cycle restore wiped, for the audit trail. */ +export interface CycleRestoreCleared { + cycles: number; + cycleDayLogs: number; + cycleProfile: number; +} + +// Closed enum allow-lists. The backup schema is `.passthrough()`, so a +// malformed enum value would otherwise crash deep in `create()`. Guard the +// few enum columns up-front (mirrors the measurement-type guard in the +// restore route). Unknown values are coerced to null rather than failing +// the whole restore — a single drifted field shouldn't strand the rest. +const FLOW_LEVELS = new Set(["NONE", "SPOTTING", "LIGHT", "MEDIUM", "HEAVY"]); +const OVULATION_TESTS = new Set([ + "NEGATIVE", + "POSITIVE_LH_SURGE", + "ESTROGEN_SURGE", + "INDETERMINATE", +]); +const CERVICAL_MUCUS = new Set([ + "DRY", + "STICKY", + "CREAMY", + "WATERY", + "EGG_WHITE", +]); +const HOME_TESTS = new Set(["NEGATIVE", "POSITIVE", "INDETERMINATE"]); +const CONTRACEPTIVES = new Set([ + "NONE", + "UNSPECIFIED", + "IMPLANT", + "INJECTION", + "IUD", + "INTRAVAGINAL_RING", + "ORAL", + "PATCH", + "EMERGENCY", +]); +const CYCLE_SOURCES = new Set(["MANUAL", "WITHINGS", "IMPORT", "APPLE_HEALTH"]); +const CYCLE_GOALS = new Set([ + "GENERAL_HEALTH", + "AVOID_PREGNANCY", + "TRYING_TO_CONCEIVE", + "PERIMENOPAUSE", + "OFF", +]); + +function enumOrNull( + value: string | null | undefined, + allow: Set, +): T | null { + return value != null && allow.has(value) ? (value as T) : null; +} + +/** + * Restore the cycle tables for `ownerId` from a parsed backup payload, + * inside an existing transaction. Delete-then-recreate, matching the + * measurement/mood restore contract. Symptom links are re-resolved against + * the seeded catalogue by key (unknown keys dropped). `notesEncrypted` is + * written back as the ciphertext envelope verbatim — never re-encrypted. + * + * Returns the wiped counts for the audit trail. A pre-v1.15 payload (empty + * cycle arrays + null profile) wipes nothing and recreates nothing. + */ +export async function restoreCycleData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: BackupPayload, +): Promise { + // Wipe (child links cascade off the day-log delete). + const dayLogs = await tx.cycleDayLog.deleteMany({ where: { userId: ownerId } }); + const cycles = await tx.menstrualCycle.deleteMany({ where: { userId: ownerId } }); + const profile = await tx.cycleProfile.deleteMany({ where: { userId: ownerId } }); + + // Recreate the profile (one row / user). + if (payload.cycleProfile) { + const p = payload.cycleProfile; + await tx.cycleProfile.create({ + data: { + userId: ownerId, + goal: (enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH") as never, + cycleTrackingEnabled: p.cycleTrackingEnabled ?? null, + typicalCycleLength: p.typicalCycleLength ?? null, + typicalPeriodLength: p.typicalPeriodLength ?? null, + lutealPhaseLength: p.lutealPhaseLength ?? null, + predictionEnabled: p.predictionEnabled ?? true, + rawChartMode: p.rawChartMode ?? false, + discreetNotifications: p.discreetNotifications ?? false, + sensitiveCategoryEncryption: p.sensitiveCategoryEncryption ?? true, + }, + }); + } + + // Recreate observed cycle spans; map startDate → id so day-logs can + // re-attach to their owning span. + const cycleIdByStart = new Map(); + for (const c of payload.cycles) { + const created = await tx.menstrualCycle.create({ + data: { + userId: ownerId, + startDate: c.startDate, + endDate: c.endDate ?? null, + periodEndDate: c.periodEndDate ?? null, + lengthDays: c.lengthDays ?? null, + ovulationDate: c.ovulationDate ?? null, + ovulationConfirmed: c.ovulationConfirmed ?? false, + tz: c.tz ?? null, + isPredicted: false, + }, + }); + cycleIdByStart.set(c.startDate, created.id); + } + + // Resolve the seeded symptom catalogue once for the link re-creation. + const allKeys = Array.from( + new Set(payload.cycleDayLogs.flatMap((d) => d.symptomKeys ?? [])), + ); + const symptomIdByKey = new Map(); + if (allKeys.length > 0) { + const rows = await tx.cycleSymptom.findMany({ + where: { key: { in: allKeys }, OR: [{ userId: null }, { userId: ownerId }] }, + select: { id: true, key: true }, + }); + for (const r of rows) symptomIdByKey.set(r.key, r.id); + } + + // Recreate day-logs (with symptom links). The owning cycle is the latest + // span whose start is on/before the day (the cycle-attribution rule). + const sortedStarts = payload.cycles + .map((c) => c.startDate) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const owningCycleId = (date: string): string | null => { + let id: string | null = null; + for (const start of sortedStarts) { + if (start <= date) id = cycleIdByStart.get(start) ?? id; + else break; + } + return id; + }; + + for (const d of payload.cycleDayLogs) { + const symptomIds = (d.symptomKeys ?? []) + .map((k) => symptomIdByKey.get(k)) + .filter((v): v is string => v !== undefined); + await tx.cycleDayLog.create({ + data: { + userId: ownerId, + date: d.date, + cycleId: owningCycleId(d.date), + flow: enumOrNull(d.flow, FLOW_LEVELS) as never, + intermenstrualBleeding: d.intermenstrualBleeding ?? false, + basalBodyTempC: d.basalBodyTempC ?? null, + ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never, + cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never, + sexualActivity: d.sexualActivity ?? false, + protectedSex: d.protectedSex ?? null, + pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS) as never, + progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS) as never, + contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES) as never, + // Ciphertext envelope written back verbatim — never re-encrypted. + notesEncrypted: d.notesEncrypted ?? null, + source: (enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL") as never, + externalId: d.externalId ?? null, + tz: d.tz ?? null, + ...(symptomIds.length > 0 + ? { + symptomLinks: { + create: symptomIds.map((symptomId) => ({ symptomId })), + }, + } + : {}), + }, + }); + } + + return { + cycles: cycles.count, + cycleDayLogs: dayLogs.count, + cycleProfile: profile.count, + }; +} diff --git a/src/lib/cycle/export-data.ts b/src/lib/cycle/export-data.ts new file mode 100644 index 000000000..c13b73f5c --- /dev/null +++ b/src/lib/cycle/export-data.ts @@ -0,0 +1,146 @@ +/** + * Cycle export summary — the shared read both the FHIR Observation emitter + * and the doctor-PDF cycle section consume, so the two surfaces describe + * identical numbers (the source-of-truth property the export family holds). + * + * Reads cycle data scoped to `userId` + `deletedAt: null`. Computes the + * concise clinical summary: last menstrual period (LMP), recent observed + * cycles, average length + variability, average period length, and the + * current phase. Pure stats; no prediction (predictions are forecasts, not + * a clinical record of what happened). + * + * `notesEncrypted` is NEVER touched here — the export summary is + * statistics only, so no plaintext free-text ever reaches a report surface + * through this path. + */ +import { prisma } from "@/lib/db"; +import { median, mad } from "@/lib/cycle/prediction"; +import { phaseForDay } from "@/lib/cycle/phase"; +import { dayDiff, addDays } from "@/lib/cycle/day-math"; +import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types"; + +/** One observed cycle in the recent-cycles table. */ +export interface CycleExportCycle { + /** YYYY-MM-DD period start. */ + startDate: string; + /** Observed cycle length in days (start→next start), null for the open cycle. */ + lengthDays: number | null; + /** Observed period (bleeding) length in days, null when unknown. */ + periodLengthDays: number | null; +} + +/** The concise cycle summary surfaced in FHIR + the doctor PDF. */ +export interface CycleExportSummary { + /** YYYY-MM-DD of the most recent period start (LMP). */ + lastPeriodStart: string | null; + /** Observed cycles within the window, newest first (capped). */ + recentCycles: CycleExportCycle[]; + /** Count of observed cycles used for the stats. */ + observedCycleCount: number; + /** Median observed cycle length (days). Null with < 1 completed cycle. */ + averageCycleLengthDays: number | null; + /** + * Cycle-length variability (days) — the median absolute deviation of the + * observed lengths, a robust spread measure. Null with < 2 cycles. + */ + cycleLengthVariabilityDays: number | null; + /** Median observed period length (days). Null when no period length known. */ + averagePeriodLengthDays: number | null; + /** The phase the report date falls in, or null when it can't be resolved. */ + currentPhase: CyclePhase | null; +} + +/** Max observed cycles surfaced in the recent-cycles table. */ +const RECENT_CYCLES_CAP = 12; + +/** + * Build the cycle export summary for a user. `asOf` anchors the + * current-phase calculation (defaults to today). Returns null when the + * user has no observed cycles at all (the caller then omits the section). + */ +export async function buildCycleExportSummary( + userId: string, + asOf: string, + lutealLength: number = LUTEAL_DEFAULT, +): Promise { + const cycles = await prisma.menstrualCycle.findMany({ + where: { userId, deletedAt: null, isPredicted: false }, + orderBy: { startDate: "asc" }, + select: { + startDate: true, + endDate: true, + periodEndDate: true, + lengthDays: true, + ovulationDate: true, + }, + }); + + if (cycles.length === 0) return null; + + // Observed length: prefer the stored lengthDays, else derive from the + // next cycle's start (the canonical start→next-start definition). + const observed: CycleExportCycle[] = cycles.map((c, i) => { + const next = cycles[i + 1]; + const lengthDays = + c.lengthDays ?? + (next ? dayDiff(next.startDate, c.startDate) : null); + const periodLengthDays = + c.periodEndDate !== null + ? dayDiff(c.periodEndDate, c.startDate) + 1 + : null; + return { startDate: c.startDate, lengthDays, periodLengthDays }; + }); + + const lengths = observed + .map((c) => c.lengthDays) + .filter((n): n is number => n !== null && n > 0); + const periodLengths = observed + .map((c) => c.periodLengthDays) + .filter((n): n is number => n !== null && n > 0); + + const averageCycleLengthDays = + lengths.length >= 1 ? Math.round(median(lengths) * 10) / 10 : null; + const cycleLengthVariabilityDays = + lengths.length >= 2 + ? Math.round(mad(lengths, median(lengths)) * 10) / 10 + : null; + const averagePeriodLengthDays = + periodLengths.length >= 1 + ? Math.round(median(periodLengths) * 10) / 10 + : null; + + const lastPeriodStart = cycles[cycles.length - 1]?.startDate ?? null; + + // Current phase: resolve against the cycle whose window contains `asOf`. + let currentPhase: CyclePhase | null = null; + const last = cycles[cycles.length - 1]; + if (last) { + // The open cycle's nextStart is unknown; estimate it from the median + // length so the phase math has a window. + const estLength = averageCycleLengthDays ?? 28; + const nextStart = last.endDate + ? addDays(last.endDate, 1) + : addDays(last.startDate, Math.round(estLength)); + const periodLen = observed[observed.length - 1]?.periodLengthDays ?? null; + const { phase } = phaseForDay(asOf, { + startDate: last.startDate, + nextStart, + ovulationDate: last.ovulationDate, + periodLength: periodLen, + lutealLength, + }); + currentPhase = phase; + } + + return { + lastPeriodStart, + recentCycles: observed + .slice(-RECENT_CYCLES_CAP) + .reverse(), + observedCycleCount: observed.length, + averageCycleLengthDays, + cycleLengthVariabilityDays, + averagePeriodLengthDays, + currentPhase, + }; +} diff --git a/src/lib/cycle/healthkit-mapping.ts b/src/lib/cycle/healthkit-mapping.ts new file mode 100644 index 000000000..c918f0203 --- /dev/null +++ b/src/lib/cycle/healthkit-mapping.ts @@ -0,0 +1,355 @@ +/** + * HealthKit reproductive-category → CycleDayLog / CycleProfile mapping. + * + * The single source of truth for how an Apple Health reproductive sample + * folds into HealthLog's cycle model. Both the `export.xml` streaming + * importer (`src/lib/measurements/import-apple-health-export.ts`) and the + * iOS batch contract reference this table; the canonical key tables also + * live in `docs/api/cycle-healthkit-mapping.md` so a self-hoster reading + * the importer and a developer reading the docs see one mapping. + * + * Routing rules (per `.planning/v1.15-cycle/ios-contract.md` §5): + * - MenstrualFlow / IntermenstrualBleeding / CervicalMucusQuality / + * OvulationTestResult / SexualActivity / Pregnancy/Progesterone test + * results → fields on a `CycleDayLog` (the day-log write helper), NOT + * `Measurement`. + * - Pregnancy / Lactation / Contraceptive STATUS → `CycleProfile` flags + * (post-partum / contraception mode), NOT day-logs. + * - Symptom category types → seeded `CycleSymptom` keys via the + * `CycleSymptomLink` join. + * - BBT (`basalBodyTemperature`) stays a `Measurement(BODY_TEMPERATURE)` + * AND mirrors `CycleDayLog.basalBodyTempC`; it is NOT routed here (it + * is a quantity sample handled by the measurement mapping + the cycle + * day-log mirror). + * - BleedingDuringPregnancy / BleedingAfterPregnancy stay DEFERRED + * (pregnancy-mode is a later release). + * + * Apple's `export.xml` writes a category sample's `value` as the symbolic + * `HKCategoryValue…` name; the live HealthKit API uses the integer + * codepoint. Every lookup table below is keyed by BOTH so the importer and + * the batch ingest resolve a sample regardless of which form it carries. + */ +import type { + FlowLevel, + OvulationTest, + CervicalMucus, + HomeTestResult, + ContraceptiveKind, +} from "@/generated/prisma/client"; + +/* ── HK identifier constants ─────────────────────────────────────── */ + +export const HK_MENSTRUAL_FLOW = "HKCategoryTypeIdentifierMenstrualFlow"; +export const HK_INTERMENSTRUAL_BLEEDING = + "HKCategoryTypeIdentifierIntermenstrualBleeding"; +export const HK_CERVICAL_MUCUS = "HKCategoryTypeIdentifierCervicalMucusQuality"; +export const HK_OVULATION_TEST = "HKCategoryTypeIdentifierOvulationTestResult"; +export const HK_SEXUAL_ACTIVITY = "HKCategoryTypeIdentifierSexualActivity"; +export const HK_PREGNANCY_TEST = "HKCategoryTypeIdentifierPregnancyTestResult"; +export const HK_PROGESTERONE_TEST = + "HKCategoryTypeIdentifierProgesteroneTestResult"; +export const HK_CONTRACEPTIVE = "HKCategoryTypeIdentifierContraceptive"; +export const HK_PREGNANCY = "HKCategoryTypeIdentifierPregnancy"; +export const HK_LACTATION = "HKCategoryTypeIdentifierLactation"; + +/** + * The HealthKit metadata key carrying the protection-used boolean on a + * SexualActivity sample. Apple writes it as a `` child with + * a `"0"`/`"1"` (or `"true"`/`"false"`) value. + */ +export const HK_SEXUAL_ACTIVITY_PROTECTION_META = + "HKMetadataKeySexualActivityProtectionUsed"; + +/* ── category-value lookup tables (symbolic name + integer) ──────── */ + +/** + * HKCategoryValueMenstrualFlow: unspecified(1)/light(2)/medium(3)/heavy(4)/ + * none(5). HK `unspecified` maps to LIGHT as the conservative boundary + * (a logged-but-unspecified bleeding day is at least light flow); HK + * `none` maps to our NONE. Documented with iOS in the spotting boundary + * note — HealthLog's SPOTTING has no HK counterpart, so it is only ever + * produced by a manual MANUAL-source entry. + */ +export const HK_MENSTRUAL_FLOW_VALUES: Record = { + "1": "LIGHT", // HKCategoryValueMenstrualFlowUnspecified + HKCategoryValueMenstrualFlowUnspecified: "LIGHT", + "2": "LIGHT", // HKCategoryValueMenstrualFlowLight + HKCategoryValueMenstrualFlowLight: "LIGHT", + "3": "MEDIUM", // HKCategoryValueMenstrualFlowMedium + HKCategoryValueMenstrualFlowMedium: "MEDIUM", + "4": "HEAVY", // HKCategoryValueMenstrualFlowHeavy + HKCategoryValueMenstrualFlowHeavy: "HEAVY", + "5": "NONE", // HKCategoryValueMenstrualFlowNone + HKCategoryValueMenstrualFlowNone: "NONE", +}; + +/** + * HKCategoryValueOvulationTestResult: negative(1)/luteinizingHormoneSurge(2)/ + * indeterminate(3)/estrogenSurge(4). + */ +export const HK_OVULATION_TEST_VALUES: Record = { + "1": "NEGATIVE", + HKCategoryValueOvulationTestResultNegative: "NEGATIVE", + "2": "POSITIVE_LH_SURGE", + HKCategoryValueOvulationTestResultLuteinizingHormoneSurge: "POSITIVE_LH_SURGE", + "3": "INDETERMINATE", + HKCategoryValueOvulationTestResultIndeterminate: "INDETERMINATE", + "4": "ESTROGEN_SURGE", + HKCategoryValueOvulationTestResultEstrogenSurge: "ESTROGEN_SURGE", +}; + +/** + * HKCategoryValueCervicalMucusQuality: dry(1)/sticky(2)/creamy(3)/watery(4)/ + * eggWhite(5). + */ +export const HK_CERVICAL_MUCUS_VALUES: Record = { + "1": "DRY", + HKCategoryValueCervicalMucusQualityDry: "DRY", + "2": "STICKY", + HKCategoryValueCervicalMucusQualitySticky: "STICKY", + "3": "CREAMY", + HKCategoryValueCervicalMucusQualityCreamy: "CREAMY", + "4": "WATERY", + HKCategoryValueCervicalMucusQualityWatery: "WATERY", + "5": "EGG_WHITE", + HKCategoryValueCervicalMucusQualityEggWhite: "EGG_WHITE", +}; + +/** + * Shared home-test result enum for the pregnancy- and progesterone-test + * category types: negative(1)/positive(2)/indeterminate(3). + */ +export const HK_HOME_TEST_VALUES: Record = { + "1": "NEGATIVE", + HKCategoryValuePregnancyTestResultNegative: "NEGATIVE", + HKCategoryValueProgesteroneTestResultNegative: "NEGATIVE", + "2": "POSITIVE", + HKCategoryValuePregnancyTestResultPositive: "POSITIVE", + HKCategoryValueProgesteroneTestResultPositive: "POSITIVE", + "3": "INDETERMINATE", + HKCategoryValuePregnancyTestResultIndeterminate: "INDETERMINATE", + HKCategoryValueProgesteroneTestResultIndeterminate: "INDETERMINATE", +}; + +/** + * HKCategoryValueContraceptive: unspecified(1)/implant(2)/injection(3)/ + * intrauterineDevice(4)/intravaginalRing(5)/oral(6)/patch(7)/emergency(8). + * Routed to the CycleProfile contraception flag (status), but a same-day + * contraceptive sample also lands on the day-log `contraceptive` field so + * the timeline shows the method that was active. + */ +export const HK_CONTRACEPTIVE_VALUES: Record = { + "1": "UNSPECIFIED", + HKCategoryValueContraceptiveUnspecified: "UNSPECIFIED", + "2": "IMPLANT", + HKCategoryValueContraceptiveImplant: "IMPLANT", + "3": "INJECTION", + HKCategoryValueContraceptiveInjection: "INJECTION", + "4": "IUD", + HKCategoryValueContraceptiveIntrauterineDevice: "IUD", + "5": "INTRAVAGINAL_RING", + HKCategoryValueContraceptiveIntravaginalRing: "INTRAVAGINAL_RING", + "6": "ORAL", + HKCategoryValueContraceptiveOral: "ORAL", + "7": "PATCH", + HKCategoryValueContraceptivePatch: "PATCH", + "8": "EMERGENCY", + HKCategoryValueContraceptiveEmergency: "EMERGENCY", +}; + +/** + * Symptom category types → seeded `CycleSymptom.key` (the catalogue keys + * from migration 0129). One HK identifier per canonical symptom key; the + * symptom severity (none/mild/moderate/severe) is carried on the link's + * severity in the batch contract but the seeded catalogue join is + * key-only, so severity is dropped on the XML import path (the symptom is + * still recorded). Identifiers without a seeded counterpart are omitted — + * they fall through to the importer's `deferred` tally rather than + * inventing a catalogue row. + */ +export const HK_SYMPTOM_KEY_BY_IDENTIFIER: Record = { + HKCategoryTypeIdentifierAbdominalCramps: "cramps", + HKCategoryTypeIdentifierHeadache: "headache", + HKCategoryTypeIdentifierBloating: "bloating", + HKCategoryTypeIdentifierAcne: "acne", + HKCategoryTypeIdentifierBreastPain: "breast_tenderness", + HKCategoryTypeIdentifierFatigue: "fatigue", + HKCategoryTypeIdentifierLowerBackPain: "back_pain", + HKCategoryTypeIdentifierSleepChanges: "insomnia", + HKCategoryTypeIdentifierMoodChanges: "mood_swings", + HKCategoryTypeIdentifierAppetiteChanges: "food_cravings", + HKCategoryTypeIdentifierNausea: "nausea", +}; + +/** + * HK severity codepoints for a symptom category sample: + * notPresent(1)/present(2)/mild(3)/moderate(4)/severe(5). A `notPresent` + * sample is an explicit "I did not have this" record — it must NOT create + * a symptom link. Returns true when the sample asserts the symptom. + */ +export function hkSymptomIsPresent(rawValue: string | undefined): boolean { + if (rawValue === undefined) return true; // sample present, no severity + if ( + rawValue === "1" || + rawValue === "HKCategoryValueSeverityNotPresent" || + rawValue === "HKCategoryValuePresenceNotPresent" + ) { + return false; + } + return true; +} + +/* ── routed-output shape ─────────────────────────────────────────── */ + +/** Partial CycleDayLog field set a single HK reproductive sample fills. */ +export interface CycleDayLogFields { + flow?: FlowLevel; + intermenstrualBleeding?: boolean; + ovulationTest?: OvulationTest; + cervicalMucus?: CervicalMucus; + sexualActivity?: boolean; + protectedSex?: boolean | null; + pregnancyTest?: HomeTestResult; + progesteroneTest?: HomeTestResult; + contraceptive?: ContraceptiveKind; + /** Seeded symptom catalogue key to link on the day. */ + symptomKey?: string; +} + +/** + * CycleProfile status fields a HK STATUS sample updates. The v1.15.0 + * `CycleProfile` schema carries no dedicated pregnant/lactating columns + * (pregnancy-mode is a deferred release), so the only profile signal a + * status sample can land today is a goal nudge: an active contraceptive + * method nudges the goal toward AVOID_PREGNANCY. The nudge is conservative + * — the importer only applies it when the profile is still on its + * GENERAL_HEALTH default, never clobbering an explicit user choice. + */ +export interface CycleProfileFields { + /** + * The contraceptive method the sample asserts, also written to the + * day-log `contraceptive` field for the timeline. Drives the goal nudge. + */ + contraceptive?: ContraceptiveKind; +} + +export type HkCycleRoute = + /** Fold the fields into the day's `CycleDayLog` (the common case). */ + | { kind: "day-log"; fields: CycleDayLogFields } + /** + * A contraceptive STATUS sample: record the method on the day-log AND + * nudge the CycleProfile goal. Carries both payloads so the importer + * applies them in one pass. + */ + | { kind: "day-log+profile"; fields: CycleDayLogFields; profile: CycleProfileFields } + /** No cycle destination (unrecognised value, or deferred pregnancy-mode). */ + | { kind: "skip" }; + +/** Every HK identifier this module routes (status + day-log + symptoms). */ +export const HK_CYCLE_IDENTIFIERS: ReadonlySet = new Set([ + HK_MENSTRUAL_FLOW, + HK_INTERMENSTRUAL_BLEEDING, + HK_CERVICAL_MUCUS, + HK_OVULATION_TEST, + HK_SEXUAL_ACTIVITY, + HK_PREGNANCY_TEST, + HK_PROGESTERONE_TEST, + HK_CONTRACEPTIVE, + ...Object.keys(HK_SYMPTOM_KEY_BY_IDENTIFIER), +]); + +/** Does this module own the inbound identifier? */ +export function isCycleHkIdentifier(hkIdentifier: string): boolean { + return HK_CYCLE_IDENTIFIERS.has(hkIdentifier); +} + +/** + * Map one inbound HK reproductive sample to its cycle destination. + * + * @param hkIdentifier the `HKCategoryTypeIdentifier…` string. + * @param rawValue the sample's `value` attribute (symbolic name OR integer + * codepoint as a string), `undefined` for a presence-only sample. + * @param protectionUsed resolved `HKMetadataKeySexualActivityProtectionUsed` + * metadata for a SexualActivity sample (`undefined` when absent). + * @returns a routed destination, or `{ kind: "skip" }` when the value is + * unrecognised or the identifier carries no cycle meaning. + */ +export function mapHkCycleSample( + hkIdentifier: string, + rawValue: string | undefined, + protectionUsed?: boolean, +): HkCycleRoute { + // Symptom category types → catalogue key (skip an explicit not-present). + const symptomKey = HK_SYMPTOM_KEY_BY_IDENTIFIER[hkIdentifier]; + if (symptomKey) { + if (!hkSymptomIsPresent(rawValue)) return { kind: "skip" }; + return { kind: "day-log", fields: { symptomKey } }; + } + + switch (hkIdentifier) { + case HK_MENSTRUAL_FLOW: { + const flow = rawValue ? HK_MENSTRUAL_FLOW_VALUES[rawValue] : undefined; + if (!flow) return { kind: "skip" }; + return { kind: "day-log", fields: { flow } }; + } + case HK_INTERMENSTRUAL_BLEEDING: + // A present sample asserts spotting outside the period window. + return { kind: "day-log", fields: { intermenstrualBleeding: true } }; + case HK_CERVICAL_MUCUS: { + const cervicalMucus = rawValue + ? HK_CERVICAL_MUCUS_VALUES[rawValue] + : undefined; + if (!cervicalMucus) return { kind: "skip" }; + return { kind: "day-log", fields: { cervicalMucus } }; + } + case HK_OVULATION_TEST: { + const ovulationTest = rawValue + ? HK_OVULATION_TEST_VALUES[rawValue] + : undefined; + if (!ovulationTest) return { kind: "skip" }; + return { kind: "day-log", fields: { ovulationTest } }; + } + case HK_SEXUAL_ACTIVITY: + return { + kind: "day-log", + fields: { + sexualActivity: true, + protectedSex: protectionUsed === undefined ? null : protectionUsed, + }, + }; + case HK_PREGNANCY_TEST: { + const pregnancyTest = rawValue + ? HK_HOME_TEST_VALUES[rawValue] + : undefined; + if (!pregnancyTest) return { kind: "skip" }; + return { kind: "day-log", fields: { pregnancyTest } }; + } + case HK_PROGESTERONE_TEST: { + const progesteroneTest = rawValue + ? HK_HOME_TEST_VALUES[rawValue] + : undefined; + if (!progesteroneTest) return { kind: "skip" }; + return { kind: "day-log", fields: { progesteroneTest } }; + } + case HK_CONTRACEPTIVE: { + const contraceptive = rawValue + ? HK_CONTRACEPTIVE_VALUES[rawValue] + : undefined; + if (!contraceptive) return { kind: "skip" }; + // Record the method on the day-log timeline AND nudge the profile + // goal toward AVOID_PREGNANCY (the importer applies the nudge only + // when the goal is still on its GENERAL_HEALTH default). + return { + kind: "day-log+profile", + fields: { contraceptive }, + profile: { contraceptive }, + }; + } + // HK_PREGNANCY / HK_LACTATION carry no v1.15.0 schema destination + // (pregnancy-mode is deferred); they fall through to skip and the + // importer leaves them in its `deferred` tally rather than inventing a + // status column. + default: + return { kind: "skip" }; + } +} diff --git a/src/lib/cycle/import-accumulator.ts b/src/lib/cycle/import-accumulator.ts new file mode 100644 index 000000000..82030dfa6 --- /dev/null +++ b/src/lib/cycle/import-accumulator.ts @@ -0,0 +1,243 @@ +/** + * Cycle-import accumulator — folds the reproductive HealthKit samples a + * `export.xml` parse emits into one merged `CycleDayLog` per user-local day. + * + * The streaming importer (`import-apple-health-export.ts`) sees one + * `` per HK sample. Several reproductive samples can share a day + * (flow + cervical mucus + a symptom). The `CycleDayLog` is one canonical + * row per `(user, date)`, so we merge same-day samples in memory and flush + * one upsert per day at the end of the parse. + * + * Dedup / idempotency: each merged day writes with `source:APPLE_HEALTH` and + * a synthetic per-day `externalId` (`hkcycle:`), so a re-import + * upserts the same row on the `(userId, source, externalId)` NULL-distinct + * key — first-write-wins at the day grain, last-writer-wins on the field + * set (the day-log write helper's contract). This mirrors the cumulative + * `stats::` convention the measurement importer already uses. + * + * A contraceptive STATUS sample additionally nudges the `CycleProfile` + * goal toward AVOID_PREGNANCY — but only when the goal is still on its + * GENERAL_HEALTH default, so an explicit user choice is never clobbered. + */ +import { prisma } from "@/lib/db"; +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +import { findOwningCycleId } from "@/lib/cycle/cycle-attribution"; +import { + mapHkCycleSample, + isCycleHkIdentifier, + type CycleDayLogFields, +} from "@/lib/cycle/healthkit-mapping"; +import type { CycleDayLogInput } from "@/lib/validations/cycle"; +import type { ContraceptiveKind } from "@/generated/prisma/client"; + +/** Synthetic per-day externalId prefix for HealthKit-imported cycle rows. */ +export const HK_CYCLE_DAY_EXTERNAL_PREFIX = "hkcycle:"; + +/** Per-day merge state held during the parse. */ +interface DayBucket { + fields: CycleDayLogFields; + symptomKeys: Set; + /** A contraceptive method seen this day → triggers the profile nudge. */ + contraceptiveNudge: ContraceptiveKind | null; +} + +/** Terminal stats the importer folds into its `ImportJobResult`. */ +export interface CycleImportStats { + /** Reproductive samples consumed into a day-bucket. */ + samplesConsumed: number; + /** Day-logs upserted at flush. */ + daysUpserted: number; + /** Day-logs that the write created (vs refreshed). */ + daysInserted: number; + /** Whether a CycleProfile goal nudge fired. */ + goalNudged: boolean; +} + +/** + * In-memory fold of the reproductive HK samples for one user's import. + * Construct once per parse; `consume()` per matching ``; `flush()` + * once at the end. + */ +export class CycleImportAccumulator { + private readonly byDay = new Map(); + private samplesConsumed = 0; + private gated: boolean | null = null; + + constructor( + private readonly userId: string, + private readonly userTimezone: string, + ) {} + + /** Does this importer own the inbound HK identifier? */ + static handles(hkIdentifier: string): boolean { + return isCycleHkIdentifier(hkIdentifier); + } + + /** True once at least one reproductive sample folded into a day-bucket. */ + hasSamples(): boolean { + return this.byDay.size > 0; + } + + /** + * Fold one reproductive HK sample into its day-bucket. `dayKey` is the + * already-resolved user-local `YYYY-MM-DD`. `rawValue` is the sample's + * `value` attribute (symbolic name or integer string). `protectionUsed` + * is the resolved SexualActivity protection metadata (or undefined). + * Returns true when the sample was consumed (recognised + routed). + */ + consume( + hkIdentifier: string, + dayKey: string, + rawValue: string | undefined, + protectionUsed?: boolean, + ): boolean { + const route = mapHkCycleSample(hkIdentifier, rawValue, protectionUsed); + if (route.kind === "skip") return false; + + const bucket = this.bucketFor(dayKey); + this.mergeFields(bucket, route.fields); + + if (route.kind === "day-log+profile" && route.profile.contraceptive) { + bucket.contraceptiveNudge = route.profile.contraceptive; + } + + this.samplesConsumed += 1; + return true; + } + + private bucketFor(dayKey: string): DayBucket { + let bucket = this.byDay.get(dayKey); + if (!bucket) { + bucket = { fields: {}, symptomKeys: new Set(), contraceptiveNudge: null }; + this.byDay.set(dayKey, bucket); + } + return bucket; + } + + /** Last-writer-wins merge of a sample's fields into the day-bucket. */ + private mergeFields(bucket: DayBucket, fields: CycleDayLogFields): void { + if (fields.symptomKey) bucket.symptomKeys.add(fields.symptomKey); + if (fields.flow !== undefined) bucket.fields.flow = fields.flow; + if (fields.intermenstrualBleeding !== undefined) { + bucket.fields.intermenstrualBleeding = fields.intermenstrualBleeding; + } + if (fields.ovulationTest !== undefined) { + bucket.fields.ovulationTest = fields.ovulationTest; + } + if (fields.cervicalMucus !== undefined) { + bucket.fields.cervicalMucus = fields.cervicalMucus; + } + if (fields.sexualActivity !== undefined) { + bucket.fields.sexualActivity = fields.sexualActivity; + } + if (fields.protectedSex !== undefined) { + bucket.fields.protectedSex = fields.protectedSex; + } + if (fields.pregnancyTest !== undefined) { + bucket.fields.pregnancyTest = fields.pregnancyTest; + } + if (fields.progesteroneTest !== undefined) { + bucket.fields.progesteroneTest = fields.progesteroneTest; + } + if (fields.contraceptive !== undefined) { + bucket.fields.contraceptive = fields.contraceptive; + } + } + + /** + * Resolve whether cycle tracking is enabled for the user (gender-derived + * or the explicit toggle). Cached for the parse lifetime. The importer + * skips the whole cycle fold when this is false so a non-cycle account's + * Apple Health export never silently provisions cycle rows. + */ + async isEnabled(): Promise { + if (this.gated !== null) return this.gated; + const [user, profile] = await Promise.all([ + prisma.user.findUnique({ + where: { id: this.userId }, + select: { gender: true }, + }), + prisma.cycleProfile.findUnique({ + where: { userId: this.userId }, + select: { cycleTrackingEnabled: true }, + }), + ]); + // Inline the gate logic (no profile auto-create on the import path). + const toggle = profile?.cycleTrackingEnabled; + this.gated = + toggle === true + ? true + : toggle === false + ? false + : user?.gender === "FEMALE"; + return this.gated; + } + + /** + * Upsert one merged `CycleDayLog` per accumulated day. No-op when nothing + * was consumed. Returns the terminal stats for the importer result. + */ + async flush(): Promise { + const stats: CycleImportStats = { + samplesConsumed: this.samplesConsumed, + daysUpserted: 0, + daysInserted: 0, + goalNudged: false, + }; + if (this.byDay.size === 0) return stats; + + let contraceptiveNudge: ContraceptiveKind | null = null; + + for (const [dayKey, bucket] of this.byDay.entries()) { + const symptoms = + bucket.symptomKeys.size > 0 + ? Array.from(bucket.symptomKeys).map((key) => ({ key })) + : undefined; + + const entry: CycleDayLogInput = { + date: dayKey, + ...bucket.fields, + ...(symptoms ? { symptoms } : {}), + source: "APPLE_HEALTH", + externalId: `${HK_CYCLE_DAY_EXTERNAL_PREFIX}${dayKey}`, + // The day-log write helper requires a `loggedAt`; anchor it to noon + // of the day so it is stable across re-imports (the row's measured + // moment is the day, not a wall-clock instant from the sample). + loggedAt: `${dayKey}T12:00:00.000Z`, + }; + + const cycleId = await findOwningCycleId(this.userId, dayKey); + const result = await upsertCycleDayLog( + this.userId, + entry, + this.userTimezone, + cycleId, + ); + stats.daysUpserted += 1; + if (!result.existed) stats.daysInserted += 1; + + // The most-recent contraceptive method across the import drives the + // single goal nudge (applied once after the day loop). + if (bucket.contraceptiveNudge) { + contraceptiveNudge = bucket.contraceptiveNudge; + } + } + + // Goal nudge: an active contraceptive method moves a still-default + // GENERAL_HEALTH profile to AVOID_PREGNANCY. NONE/UNSPECIFIED do not + // assert avoidance intent, so they leave the goal untouched. + if ( + contraceptiveNudge && + contraceptiveNudge !== "NONE" && + contraceptiveNudge !== "UNSPECIFIED" + ) { + const updated = await prisma.cycleProfile.updateMany({ + where: { userId: this.userId, goal: "GENERAL_HEALTH" }, + data: { goal: "AVOID_PREGNANCY" }, + }); + if (updated.count > 0) stats.goalNudged = true; + } + + return stats; + } +} diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index e2c71a303..d25d40473 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -23,6 +23,7 @@ import { DEFAULT_DOCTOR_REPORT_PREFS, type DoctorReportPrefs, } from "@/lib/validations/doctor-report-prefs"; +import { buildCycleExportSummary } from "@/lib/cycle/export-data"; export interface DoctorReportStats { avg: number; @@ -192,6 +193,17 @@ export interface DoctorReportData { /** ISO timestamp of the latest score. */ latestAt: string; }> | null; + /** + * v1.15.0 — concise menstrual-cycle summary (LMP, recent cycles, avg + * length + variability, current phase). Populated ONLY when the `cycle` + * section toggle is explicitly ON (privacy default OFF — a user sharing a + * BP report with a cardiologist must not auto-leak reproductive data) AND + * the user has at least one observed cycle. Statistics only — no free-text + * notes ever reach this surface. Null/absent otherwise; both the PDF and + * FHIR builders then skip the section. Optional so pre-v1.15 fixtures + * still typecheck. + */ + cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null; } /** The three persisted score types surfaced in the wellness summary. */ @@ -801,6 +813,18 @@ export async function collectDoctorReportData( const wellnessScores = wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null; + // v1.15.0 — cycle summary. Privacy default OFF: only read + assemble when + // the `cycle` section toggle is explicitly ON. Statistics only — the + // helper never touches `notesEncrypted`, so no plaintext free-text can + // leak through this surface. `null` when disabled or no observed cycle. + let cycle: DoctorReportData["cycle"] = null; + if (sections.cycle) { + cycle = await buildCycleExportSummary( + userId, + end.toISOString().slice(0, 10), + ); + } + return { period: { days, @@ -848,6 +872,7 @@ export async function collectDoctorReportData( mood, glp1, wellnessScores, + cycle, }; } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index c21a43327..55506fef9 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -1222,6 +1222,98 @@ export function buildDoctorReportPdfDocument( .finalY + 8; } + // v1.15.0 — cycle / reproductive-health section. Privacy default OFF: the + // aggregator only populates `data.cycle` when the cycle section toggle is + // explicitly ON AND the user has an observed cycle. Statistics only (no + // free-text notes). Skipped entirely otherwise. + if (data.cycle) { + const cyc = data.cycle; + y = ensureSpace(y, 6 + 6 + estimateTableHeight(1)); + + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.text(t("doctorReport.cycleTitle"), margin, y); + y += 6; + + // Summary line: LMP, avg length ± variability, avg period, phase. + doc.setFontSize(9); + doc.setFont("helvetica", "normal"); + const summaryBits: string[] = []; + if (cyc.lastPeriodStart) { + summaryBits.push( + `${t("doctorReport.cycleLmp")}: ${fmtDate(`${cyc.lastPeriodStart}T12:00:00.000Z`)}`, + ); + } + if (cyc.averageCycleLengthDays !== null) { + const varSuffix = + cyc.cycleLengthVariabilityDays !== null + ? ` (± ${num(cyc.cycleLengthVariabilityDays, 1)})` + : ""; + summaryBits.push( + `${t("doctorReport.cycleAvgLength")}: ${num(cyc.averageCycleLengthDays, 1)} ${t("doctorReport.cycleDays")}${varSuffix}`, + ); + } + if (cyc.averagePeriodLengthDays !== null) { + summaryBits.push( + `${t("doctorReport.cycleAvgPeriod")}: ${num(cyc.averagePeriodLengthDays, 1)} ${t("doctorReport.cycleDays")}`, + ); + } + if (cyc.currentPhase) { + summaryBits.push( + `${t("doctorReport.cyclePhase")}: ${t(`doctorReport.cyclePhases.${cyc.currentPhase}`)}`, + ); + } + for (const line of doc.splitTextToSize( + summaryBits.join(" • "), + pageWidth - margin * 2, + )) { + doc.text(line, margin, y); + y += 4.5; + } + y += 2; + + if (cyc.recentCycles.length > 0) { + const cycleRows = cyc.recentCycles.map((c) => [ + fmtDate(`${c.startDate}T12:00:00.000Z`), + c.lengthDays !== null ? String(c.lengthDays) : "—", + c.periodLengthDays !== null ? String(c.periodLengthDays) : "—", + ]); + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.cycleColStart"), + t("doctorReport.cycleColLength"), + t("doctorReport.cycleColPeriod"), + ], + ], + body: cycleRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + } + // v1.7.0 — optional AI summary. OUT of the clinical PDF by default; // rendered ONLY when the user explicitly opted in. Clearly labelled and // flagged as not clinically validated so a physician never mistakes it diff --git a/src/lib/fhir/__tests__/cycle-observations.test.ts b/src/lib/fhir/__tests__/cycle-observations.test.ts new file mode 100644 index 000000000..b9f61f25d --- /dev/null +++ b/src/lib/fhir/__tests__/cycle-observations.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { cycleObservationsFromReportData } from "@/lib/fhir/resources"; +import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; +import { + LMP_LOINC, + CYCLE_LENGTH_LOINC, + PERIOD_LENGTH_LOINC, +} from "@/lib/fhir/loinc-map"; + +function baseData(overrides?: Partial): DoctorReportData { + return { + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T12:00:00.000Z", + }, + patient: { + username: "sample-user", + dateOfBirth: "1990-06-15T00:00:00.000Z", + gender: "FEMALE", + heightCm: 168, + }, + practiceName: null, + measurements: {}, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [], + mood: null, + glp1: null, + ...overrides, + }; +} + +const CYCLE_SUMMARY: NonNullable = { + lastPeriodStart: "2026-04-20", + recentCycles: [ + { startDate: "2026-04-20", lengthDays: null, periodLengthDays: 5 }, + { startDate: "2026-03-23", lengthDays: 28, periodLengthDays: 5 }, + { startDate: "2026-02-24", lengthDays: 27, periodLengthDays: 4 }, + ], + observedCycleCount: 3, + averageCycleLengthDays: 27.5, + cycleLengthVariabilityDays: 0.5, + averagePeriodLengthDays: 5, + currentPhase: "LUTEAL", +}; + +describe("fhir/cycle observations", () => { + it("emits nothing when no cycle summary is present", () => { + expect(cycleObservationsFromReportData(baseData())).toEqual([]); + }); + + it("emits LMP, cycle length, period length, and phase", () => { + const obs = cycleObservationsFromReportData( + baseData({ cycle: CYCLE_SUMMARY }), + ); + const codes = obs.flatMap((o) => + (o.code.coding ?? []).map((c) => c.code).filter(Boolean), + ); + expect(codes).toContain(LMP_LOINC); + expect(codes).toContain(CYCLE_LENGTH_LOINC); + expect(codes).toContain(PERIOD_LENGTH_LOINC); + + const lmp = obs.find((o) => + (o.code.coding ?? []).some((c) => c.code === LMP_LOINC), + ); + expect(lmp?.valueDateTime).toBe("2026-04-20"); + + const phase = obs.find((o) => o.code.text === "Current menstrual cycle phase"); + expect(phase?.valueString).toBe("Luteal phase"); + + // Cycle observations carry their own id sequence, never `obs-N`. + expect(obs.every((o) => o.id.startsWith("obs-cycle-"))).toBe(true); + }); + + it("adds a 'Menstrual cycle' Composition section to the document bundle", () => { + const bundle = buildFhirDocumentBundle( + baseData({ cycle: CYCLE_SUMMARY }), + { insuranceNumber: null }, + ); + const composition = bundle.entry?.find( + (e) => e.resource.resourceType === "Composition", + )?.resource as { section?: Array<{ title: string }> } | undefined; + const titles = composition?.section?.map((s) => s.title) ?? []; + expect(titles).toContain("Menstrual cycle"); + }); + + it("omits the cycle section entirely when the summary is null", () => { + const bundle = buildFhirDocumentBundle( + baseData({ cycle: null }), + { insuranceNumber: null }, + ); + const composition = bundle.entry?.find( + (e) => e.resource.resourceType === "Composition", + )?.resource as { section?: Array<{ title: string }> } | undefined; + const titles = composition?.section?.map((s) => s.title) ?? []; + expect(titles).not.toContain("Menstrual cycle"); + }); +}); diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 03db95562..506a370c6 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -32,6 +32,7 @@ import { patientResource, coverageResource, observationsFromReportData, + cycleObservationsFromReportData, medicationStatementsFromReportData, medicationAdministrationsFromReportData, } from "@/lib/fhir/resources"; @@ -80,6 +81,7 @@ export function buildFhirDocumentBundle( }; const entries: FhirBundleEntry[] = []; const observationRefs: FhirReference[] = []; + const cycleObservationRefs: FhirReference[] = []; const medicationRefs: FhirReference[] = []; const administrationRefs: FhirReference[] = []; @@ -99,6 +101,15 @@ export function buildFhirDocumentBundle( observationRefs.push({ reference: `Observation/${obs.id}` }); } + // --- Cycle / reproductive-health Observations (opt-in only) ----------- + // v1.15.0 — grouped into their own "Menstrual cycle" Composition section + // so a reproductive-health finding is never mixed into the vital-signs + // panel. Absent unless the user toggled the cycle section AND has data. + for (const obs of cycleObservationsFromReportData(data)) { + entries.push({ fullUrl: `urn:uuid:${obs.id}`, resource: obs }); + cycleObservationRefs.push({ reference: `Observation/${obs.id}` }); + } + // --- MedicationStatement per active medication ------------------------- for (const stmt of medicationStatementsFromReportData(data, options)) { entries.push({ fullUrl: `urn:uuid:${stmt.id}`, resource: stmt }); @@ -175,6 +186,16 @@ export function buildFhirDocumentBundle( }, ] : []), + // v1.15.0 — opt-in reproductive-health section. Only present when the + // cycle toggle surfaced cycle Observations. + ...(cycleObservationRefs.length > 0 + ? [ + { + title: "Menstrual cycle", + entry: cycleObservationRefs, + }, + ] + : []), ], }; diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index c54f2afdf..cd65a48af 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -308,3 +308,11 @@ export const GLUCOSE_LOINC: Record = export const MEDICATION_ADHERENCE_LOINC = "71799-1"; /** Mood Observation LOINC (opt-in only). */ export const MOOD_LOINC = "76542-6"; + +/* ── Cycle / reproductive-health LOINCs (v1.15.0, opt-in only) ──────── */ +/** Last menstrual period (LMP) start date. */ +export const LMP_LOINC = "8665-2"; +/** Menstrual cycle length [time]. */ +export const CYCLE_LENGTH_LOINC = "64700-8"; +/** Length of menses (period / bleeding duration). */ +export const PERIOD_LENGTH_LOINC = "64698-4"; diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts index b20972a33..f970e1216 100644 --- a/src/lib/fhir/resources.ts +++ b/src/lib/fhir/resources.ts @@ -31,6 +31,9 @@ import { GLUCOSE_LOINC, MEDICATION_ADHERENCE_LOINC, MOOD_LOINC, + LMP_LOINC, + CYCLE_LENGTH_LOINC, + PERIOD_LENGTH_LOINC, type LoincMapping, } from "@/lib/fhir/loinc-map"; import type { @@ -593,6 +596,145 @@ export function observationsFromReportData( return observations; } +/** + * Phase → SNOMED-free display string for the current-phase Observation. + * The four cycle phases have no single published LOINC answer-list term we + * commit to, so the phase rides a text-only concept under the `survey` + * category (a clinician's viewer reads it as a descriptive finding, not a + * coded diagnosis), mirroring the wellness-score stance. + */ +const CYCLE_PHASE_DISPLAY: Record = { + MENSTRUAL: "Menstrual phase", + FOLLICULAR: "Follicular phase", + OVULATORY: "Ovulatory phase", + LUTEAL: "Luteal phase", +}; + +/** + * v1.15.0 — emit the cycle / reproductive-health Observations from the + * opt-in cycle summary: LMP (LOINC 8665-2, a date value), average cycle + * length (64700-8, days), average period length (64698-4, days), and the + * current phase (text-only survey finding). Absent when the aggregator + * carried no cycle summary (toggle off or no observed cycle). Ids run + * `obs-cycle-N` so they never collide with the main `obs-N` sequence. + */ +export function cycleObservationsFromReportData( + data: DoctorReportData, +): FhirObservation[] { + const cycle = data.cycle; + if (!cycle) return []; + const observations: FhirObservation[] = []; + let seq = 0; + const next = () => `obs-cycle-${(seq += 1)}`; + const effective = data.period.end; + + if (cycle.lastPeriodStart) { + observations.push({ + resourceType: "Observation", + id: next(), + status: "final", + category: [categoryConcept("survey")], + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: LMP_LOINC, + display: "Last menstrual period start date", + }, + ], + text: "Last menstrual period (LMP)", + }, + subject: patientRef, + effectiveDateTime: effective, + // LMP is a date value; emit the YYYY-MM-DD as a FHIR `date`. + valueDateTime: cycle.lastPeriodStart, + }); + } + + if (cycle.averageCycleLengthDays !== null) { + observations.push({ + resourceType: "Observation", + id: next(), + status: "final", + category: [categoryConcept("survey")], + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: CYCLE_LENGTH_LOINC, + display: "Menstrual cycle length", + }, + ], + text: "Average menstrual cycle length", + }, + subject: patientRef, + effectiveDateTime: effective, + valueQuantity: { + value: cycle.averageCycleLengthDays, + unit: "d", + system: UCUM_SYSTEM, + code: "d", + }, + ...(cycle.cycleLengthVariabilityDays !== null + ? { + note: [ + { + text: `Cycle-length variability (median absolute deviation): ±${cycle.cycleLengthVariabilityDays} days over ${cycle.observedCycleCount} observed cycle(s).`, + }, + ], + } + : {}), + }); + } + + if (cycle.averagePeriodLengthDays !== null) { + observations.push({ + resourceType: "Observation", + id: next(), + status: "final", + category: [categoryConcept("survey")], + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: PERIOD_LENGTH_LOINC, + display: "Length of menses", + }, + ], + text: "Average period length", + }, + subject: patientRef, + effectiveDateTime: effective, + valueQuantity: { + value: cycle.averagePeriodLengthDays, + unit: "d", + system: UCUM_SYSTEM, + code: "d", + }, + }); + } + + if (cycle.currentPhase) { + observations.push({ + resourceType: "Observation", + id: next(), + status: "final", + category: [categoryConcept("survey")], + code: { text: "Current menstrual cycle phase" }, + subject: patientRef, + effectiveDateTime: effective, + valueString: CYCLE_PHASE_DISPLAY[cycle.currentPhase] ?? cycle.currentPhase, + note: [ + { + text: "Phase derived from logged cycle boundaries; descriptive, not a clinical assessment.", + }, + ], + }); + } + + return observations; +} + /** * Emit one `MedicationStatement` per active medication. v1.9.0 — additive * ATC (primary) / RxNorm (secondary) codings when the user stored them; diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index d8e05fd3d..8d2845e55 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -79,6 +79,9 @@ export interface FhirObservation { valueQuantity?: FhirQuantity; valueInteger?: number; valueString?: string; + /** v1.15.0 — date-typed value (e.g. the LMP start date). */ + valueDateTime?: string; + valueCodeableConcept?: FhirCodeableConcept; component?: FhirObservationComponent[]; /** v1.10.0 — descriptive note (e.g. the wellness-score disclaimer). */ note?: FhirAnnotation[]; diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index 1682f46c2..b2ffc0398 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -111,6 +111,9 @@ describe("runOffhostBackup", () => { medication: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, }; const now = new Date("2026-05-08T03:00:00Z"); @@ -150,6 +153,9 @@ describe("runOffhostBackup", () => { medication: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, }; const report = await runOffhostBackup( // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 0e117809b..32c4f2b80 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -214,19 +214,35 @@ export async function runOffhostBackup( const evt = getEvent(); for (const user of users) { try { - const [measurements, medications, intakeEvents, moodEntries] = - await Promise.all([ - // includes soft-deleted rows because this is the DR snapshot, - // not a user-facing export — see - // `/api/export/full-backup/route.ts` for the symmetric exclusion. - prisma.measurement.findMany({ where: { userId: user.id } }), - prisma.medication.findMany({ - where: { userId: user.id }, - include: { schedules: true }, - }), - prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }), - prisma.moodEntry.findMany({ where: { userId: user.id } }), - ]); + const [ + measurements, + medications, + intakeEvents, + moodEntries, + cycleProfile, + cycles, + cycleDayLogs, + ] = await Promise.all([ + // includes soft-deleted rows because this is the DR snapshot, + // not a user-facing export — see + // `/api/export/full-backup/route.ts` for the symmetric exclusion. + prisma.measurement.findMany({ where: { userId: user.id } }), + prisma.medication.findMany({ + where: { userId: user.id }, + include: { schedules: true }, + }), + prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }), + prisma.moodEntry.findMany({ where: { userId: user.id } }), + // v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a + // disaster-recovery dump, not the canonical user-facing backup); + // `notesEncrypted` stays ciphertext. + prisma.cycleProfile.findUnique({ where: { userId: user.id } }), + prisma.menstrualCycle.findMany({ where: { userId: user.id } }), + prisma.cycleDayLog.findMany({ + where: { userId: user.id }, + include: { symptomLinks: true }, + }), + ]); const payload = JSON.stringify({ exportedAt: now.toISOString(), @@ -235,6 +251,9 @@ export async function runOffhostBackup( medications, intakeEvents, moodEntries, + cycleProfile, + cycles, + cycleDayLogs, }); const ciphertext = encryptBackup(payload, cfg.encryptionKey); diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index e80b6fac7..ed1f997c7 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -11,6 +11,7 @@ import type { Job } from "pg-boss"; import { PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { buildCycleBackupSection } from "@/lib/cycle/backup"; import { buildCanonicalSchedule, buildRecurrenceContext, @@ -2169,7 +2170,7 @@ async function handleDataBackup(jobs: Job[]) { let backed = 0; for (const user of users) { try { - const [measurements, medications, intakeEvents, moodEntries] = + const [measurements, medications, intakeEvents, moodEntries, cycle] = await Promise.all([ prisma.measurement.findMany({ where: { userId: user.id }, @@ -2188,6 +2189,8 @@ async function handleDataBackup(jobs: Job[]) { where: { userId: user.id }, orderBy: { moodLoggedAt: "desc" }, }), + // v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim). + buildCycleBackupSection(prisma, user.id), ]); const backupJson = JSON.stringify({ @@ -2231,6 +2234,10 @@ async function handleDataBackup(jobs: Job[]) { source: e.source, loggedAt: e.moodLoggedAt.toISOString(), })), + // v1.15.0 — cycle slice (profile + observed spans + day-logs). + cycleProfile: cycle.cycleProfile, + cycles: cycle.cycles, + cycleDayLogs: cycle.cycleDayLogs, }); // Encrypt the backup data (contains sensitive health information) diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index d178f693c..2687f37b9 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -202,19 +202,39 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { // Behavioural / habit "HKCategoryTypeIdentifierHandwashingEvent", "HKCategoryTypeIdentifierToothbrushingEvent", - // Reproductive / fertility / pregnancy - "HKCategoryTypeIdentifierContraceptive", + // Reproductive / fertility — v1.15.0 promoted MenstrualFlow, + // IntermenstrualBleeding, CervicalMucusQuality, OvulationTestResult, + // Contraceptive, PregnancyTestResult, ProgesteroneTestResult, + // SexualActivity and SleepChanges OUT of the deferred list (they route + // into CYCLE day-logs via the cycle accumulator). Pregnancy + + // Lactation STATUS and the four awareness types stay deferred (no + // v1.15.0 schema destination / server-derived). "HKCategoryTypeIdentifierLactation", "HKCategoryTypeIdentifierPregnancy", - "HKCategoryTypeIdentifierPregnancyTestResult", - "HKCategoryTypeIdentifierProgesteroneTestResult", - "HKCategoryTypeIdentifierSexualActivity", - "HKCategoryTypeIdentifierSleepChanges", + "HKCategoryTypeIdentifierBleedingAfterPregnancy", + "HKCategoryTypeIdentifierBleedingDuringPregnancy", "HKCategoryTypeIdentifierPersistentIntermenstrualBleeding", "HKCategoryTypeIdentifierProlongedMenstrualPeriods", "HKCategoryTypeIdentifierIrregularMenstrualCycles", "HKCategoryTypeIdentifierInfrequentMenstrualCycles", ]; + + // v1.15.0 — these are NO LONGER deferred (promoted into cycle day-logs). + for (const id of [ + "HKCategoryTypeIdentifierMenstrualFlow", + "HKCategoryTypeIdentifierIntermenstrualBleeding", + "HKCategoryTypeIdentifierCervicalMucusQuality", + "HKCategoryTypeIdentifierOvulationTestResult", + "HKCategoryTypeIdentifierContraceptive", + "HKCategoryTypeIdentifierPregnancyTestResult", + "HKCategoryTypeIdentifierProgesteroneTestResult", + "HKCategoryTypeIdentifierSexualActivity", + ]) { + expect( + HK_QUANTITY_TYPE_DEFERRED.has(id), + `${id} should NOT be deferred (promoted to cycle in v1.15.0)`, + ).toBe(false); + } for (const id of expectedLongTail) { expect( HK_QUANTITY_TYPE_DEFERRED.has(id), diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index e564cab9b..47b873bb7 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -890,13 +890,17 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // Mental-state / mindfulness — v1.5 (route to existing mood model) "HKCategoryTypeIdentifierMindfulSession", "HKDataTypeIdentifierStateOfMind", - // Pregnancy / cycle — explicit hold (privacy stance) + // Pregnancy bleeding — stays deferred (pregnancy-mode is a later + // release). v1.15.0 promoted the cycle-tracking reproductive types + // (MenstrualFlow / IntermenstrualBleeding / CervicalMucusQuality / + // OvulationTestResult and the symptom + test-result + contraceptive + // identifiers) OUT of this deferred list — they route into CYCLE + // day-logs via the importer's cycle accumulator (see + // `src/lib/cycle/healthkit-mapping.ts`), NOT into Measurement, so they + // must NOT appear here or the importer would skip them before the cycle + // branch runs. "HKCategoryTypeIdentifierBleedingAfterPregnancy", "HKCategoryTypeIdentifierBleedingDuringPregnancy", - "HKCategoryTypeIdentifierMenstrualFlow", - "HKCategoryTypeIdentifierIntermenstrualBleeding", - "HKCategoryTypeIdentifierCervicalMucusQuality", - "HKCategoryTypeIdentifierOvulationTestResult", // Clinical (FHIR) — v1.6+ "HKClinicalTypeIdentifierAllergyRecord", "HKClinicalTypeIdentifierConditionRecord", @@ -950,15 +954,16 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // Behavioural / habit category-types — not in HealthLog scope yet. "HKCategoryTypeIdentifierHandwashingEvent", "HKCategoryTypeIdentifierToothbrushingEvent", - // Reproductive / fertility / pregnancy — explicit privacy hold - // (matches the menstrual-flow stance already encoded above). - "HKCategoryTypeIdentifierContraceptive", + // Reproductive / fertility — v1.15.0 promoted Contraceptive, + // PregnancyTestResult, ProgesteroneTestResult and SexualActivity OUT of + // this list (they route into CYCLE day-logs via the cycle accumulator). + // Pregnancy + Lactation STATUS stay deferred: the v1.15.0 CycleProfile + // has no pregnant/lactating column (pregnancy-mode is a later release), + // so there is no destination yet — promoting them would only grow the + // importer's skip tally. The four awareness types are server-DERIVED + // from the cycle engine, never ingested. "HKCategoryTypeIdentifierLactation", "HKCategoryTypeIdentifierPregnancy", - "HKCategoryTypeIdentifierPregnancyTestResult", - "HKCategoryTypeIdentifierProgesteroneTestResult", - "HKCategoryTypeIdentifierSexualActivity", - "HKCategoryTypeIdentifierSleepChanges", "HKCategoryTypeIdentifierPersistentIntermenstrualBleeding", "HKCategoryTypeIdentifierProlongedMenstrualPeriods", "HKCategoryTypeIdentifierIrregularMenstrualCycles", diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index e56e622c8..9f69f3bca 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -44,6 +44,11 @@ import { } from "@/lib/measurements/drain-per-sample-cumulative"; import { resolveHkWorkoutSportType } from "@/lib/measurements/hk-workout-activity-type-map"; import { validateMeasurementRange } from "@/lib/validations/measurement"; +import { + CycleImportAccumulator, + type CycleImportStats, +} from "@/lib/cycle/import-accumulator"; +import { HK_SEXUAL_ACTIVITY_PROTECTION_META } from "@/lib/cycle/healthkit-mapping"; /** * Reverse-lookup for the symbolic `HKCategoryValueSleepAnalysis*` @@ -103,6 +108,13 @@ export interface ImportJobResult { durationMs: number; }; clinical: { skipped: number }; + /** + * v1.15.0 — reproductive HealthKit samples routed into CYCLE day-logs + * (NOT Measurement). Absent / zeroed when the account has no cycle + * tracking enabled (the fold is gated) or the export carried no + * reproductive records. + */ + cycle: CycleImportStats; deferred: Record; unknown: Record; totals: { @@ -281,6 +293,16 @@ export async function streamParseExportXml( const deferred: Record = {}; const unknown: Record = {}; + // v1.15.0 — reproductive HK samples fold into one CycleDayLog per day. + // The accumulator buckets in memory; the flush at end of parse upserts. + const cycleAccumulator = new CycleImportAccumulator(userId, userTimezone); + // Context for a reproductive `` whose protection metadata + // arrives as a following `` child (SexualActivity). Held + // between the Record open-tag and its close-tag so the child can attach. + let currentCycleRecord: + | { hkType: string; dayKey: string; rawValue: string | undefined; protectionUsed?: boolean } + | null = null; + // Cumulative-type fold: type -> dayKey -> running sum. const cumulativeBucket = new Map>(); // Spot-row batch awaiting flush. @@ -520,6 +542,22 @@ export async function streamParseExportXml( if (!hkType) return; recordsRead += 1; + // v1.15.0 — reproductive HK identifiers route into CYCLE day-logs, + // not Measurement. Defer the fold until the Record's close-tag so a + // child `` (SexualActivity protection flag) can + // attach. The accumulator's per-day bucketing handles same-day + // merges + idempotent re-import. + if (CycleImportAccumulator.handles(hkType)) { + const dayKey = dayKeyForUserTz( + new Date(attrs.endDate ?? attrs.startDate ?? ""), + userTimezone, + ); + currentCycleRecord = Number.isNaN(Date.parse(attrs.endDate ?? attrs.startDate ?? "")) + ? null + : { hkType, dayKey, rawValue: attrs.value }; + return; + } + if (HK_QUANTITY_TYPE_DEFERRED.has(hkType)) { deferred[hkType] = (deferred[hkType] ?? 0) + 1; return; @@ -666,8 +704,19 @@ export async function streamParseExportXml( return; } + if (name === "MetadataEntry") { + // Attach the SexualActivity protection flag to the open cycle record. + // Apple writes `HKMetadataKeySexualActivityProtectionUsed` with a + // `"0"`/`"1"` (or `"true"`/`"false"`) value. + if (currentCycleRecord && attrs.key === HK_SEXUAL_ACTIVITY_PROTECTION_META) { + const v = (attrs.value ?? "").toLowerCase(); + currentCycleRecord.protectionUsed = v === "1" || v === "true" || v === "yes"; + } + return; + } + if (name === "ExportDate" || name === "Me" || name === "Correlation" - || name === "ActivitySummary" || name === "MetadataEntry" + || name === "ActivitySummary" || name === "WorkoutEvent" || name === "WorkoutRoute" || name === "FileReference" || name === "HealthData") { // Known elements we intentionally ignore at the open-tag stage. @@ -686,6 +735,22 @@ export async function streamParseExportXml( workoutBatch.push(currentWorkout); currentWorkout = null; } + if (tagName === "Record" && currentCycleRecord) { + const rec = currentCycleRecord; + currentCycleRecord = null; + const consumed = cycleAccumulator.consume( + rec.hkType, + rec.dayKey, + rec.rawValue, + rec.protectionUsed, + ); + if (!consumed) { + // Recognised identifier but unrecognised value — count it under + // `unknown` with the reason tag so operators can spot it. + unknown[`${rec.hkType}::cycle_unmapped`] = + (unknown[`${rec.hkType}::cycle_unmapped`] ?? 0) + 1; + } + } }; // ── Drive the parser from a node read stream ──────────────── @@ -747,12 +812,31 @@ export async function streamParseExportXml( await flushWorkoutBatch(); await flushCumulativeBuckets(); + // v1.15.0 — fold the accumulated reproductive samples into CYCLE + // day-logs. Gated on cycle-tracking being enabled for the account so a + // non-cycle Apple Health export never silently provisions cycle rows. + let cycle: CycleImportStats = { + samplesConsumed: 0, + daysUpserted: 0, + daysInserted: 0, + goalNudged: false, + }; + // Only touch the cycle tables when the export actually carried + // reproductive samples AND the account has cycle tracking enabled. The + // empty-accumulator short-circuit also keeps the no-cycle import path + // (and its unit tests) free of any cycle DB round-trip. + if (cycleAccumulator.hasSamples() && (await cycleAccumulator.isEnabled())) { + cycle = await cycleAccumulator.flush(); + rowsUpserted += cycle.daysUpserted; + } + await emitProgress("upserting"); return { perType, workouts, clinical, + cycle, deferred, unknown, totals: { diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index ec22c03b8..92b2688ac 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -84,3 +84,67 @@ describe("moodEntrySchema.tags strict validation", () => { expect(r.success).toBe(true); }); }); + +describe("backupPayloadSchema — v1.15.0 cycle round-trip", () => { + it("defaults the cycle fields when a pre-v1.15 blob omits them", () => { + const r = backupPayloadSchema.safeParse({ + schemaVersion: "1", + exportedAt: "2026-05-08T07:00:00.000Z", + userId: "u1", + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.cycleProfile).toBeNull(); + expect(r.data.cycles).toEqual([]); + expect(r.data.cycleDayLogs).toEqual([]); + } + }); + + it("round-trips a cycle profile, observed span, and day-log with notes ciphertext", () => { + const r = backupPayloadSchema.safeParse({ + schemaVersion: "1", + exportedAt: "2026-05-08T07:00:00.000Z", + userId: "u1", + cycleProfile: { + goal: "TRYING_TO_CONCEIVE", + cycleTrackingEnabled: true, + typicalCycleLength: 28, + lutealPhaseLength: 14, + sensitiveCategoryEncryption: true, + }, + cycles: [ + { + startDate: "2026-04-20", + endDate: "2026-05-17", + periodEndDate: "2026-04-24", + lengthDays: 28, + ovulationDate: "2026-05-04", + ovulationConfirmed: true, + tz: "Europe/Berlin", + }, + ], + cycleDayLogs: [ + { + date: "2026-04-20", + flow: "MEDIUM", + intermenstrualBleeding: false, + sexualActivity: true, + protectedSex: false, + contraceptive: "NONE", + // The note must round-trip as the opaque ciphertext envelope. + notesEncrypted: "v1:deadbeef:cipher", + source: "APPLE_HEALTH", + externalId: "hkcycle:2026-04-20", + symptomKeys: ["cramps", "fatigue"], + }, + ], + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.cycleProfile?.goal).toBe("TRYING_TO_CONCEIVE"); + expect(r.data.cycles[0].lengthDays).toBe(28); + expect(r.data.cycleDayLogs[0].notesEncrypted).toBe("v1:deadbeef:cipher"); + expect(r.data.cycleDayLogs[0].symptomKeys).toEqual(["cramps", "fatigue"]); + } + }); +}); diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index 52137b5d9..faa0c3276 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -85,6 +85,7 @@ describe("resolveDoctorReportPrefs", () => { mood: true, compliance: false, sleep: false, + cycle: false, }; const out = resolveDoctorReportPrefs(current, { mood: false }); expect(out).toEqual({ ...current, mood: false }); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 1d9a899b3..1099ea1aa 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -108,6 +108,69 @@ const moodEntrySchema = z }) .passthrough(); +/* ── v1.15.0 cycle-tracking backup shapes ──────────────────────────── */ + +/** + * One menstrual-cycle span. `startDate` is the natural per-user key + * (matching the `(userId, startDate)` unique), so a restore upserts on it. + * Predicted (forecast) rows are excluded from the backup — only observed + * history round-trips. + */ +const cycleSpanSchema = z + .object({ + startDate: z.string().min(1), + endDate: z.string().nullable().optional(), + periodEndDate: z.string().nullable().optional(), + lengthDays: z.number().int().nullable().optional(), + ovulationDate: z.string().nullable().optional(), + ovulationConfirmed: z.boolean().optional(), + tz: z.string().nullable().optional(), + }) + .passthrough(); + +/** + * One cycle day-log. `notesEncrypted` is carried as the AES-256-GCM + * ciphertext envelope verbatim — the backup never decrypts it, so the + * owner's free-text note round-trips encrypted (and a wrong-surface leak is + * impossible). `symptomKeys` carries the seeded catalogue keys so the + * restore can re-link without exporting internal join ids. + */ +const cycleDayLogSchema = z + .object({ + date: z.string().min(1), + flow: z.string().nullable().optional(), + intermenstrualBleeding: z.boolean().optional(), + basalBodyTempC: z.number().nullable().optional(), + ovulationTest: z.string().nullable().optional(), + cervicalMucus: z.string().nullable().optional(), + sexualActivity: z.boolean().optional(), + protectedSex: z.boolean().nullable().optional(), + pregnancyTest: z.string().nullable().optional(), + progesteroneTest: z.string().nullable().optional(), + contraceptive: z.string().nullable().optional(), + notesEncrypted: z.string().nullable().optional(), + source: z.string().min(1).optional(), + externalId: z.string().nullable().optional(), + tz: z.string().nullable().optional(), + symptomKeys: z.array(z.string()).default([]), + }) + .passthrough(); + +/** Cycle-tracking preferences (one row per user). */ +const cycleProfileSchema = z + .object({ + goal: z.string().min(1).optional(), + cycleTrackingEnabled: z.boolean().nullable().optional(), + typicalCycleLength: z.number().int().nullable().optional(), + typicalPeriodLength: z.number().int().nullable().optional(), + lutealPhaseLength: z.number().int().nullable().optional(), + predictionEnabled: z.boolean().optional(), + rawChartMode: z.boolean().optional(), + discreetNotifications: z.boolean().optional(), + sensitiveCategoryEncryption: z.boolean().optional(), + }) + .passthrough(); + /** * Wire shape — exactly what the pg-boss worker writes today, plus a * `schemaVersion` field that newer writers stamp explicitly. Older blobs @@ -122,6 +185,11 @@ export const backupPayloadSchema = z medications: z.array(medicationSchema).default([]), intakeEvents: z.array(intakeEventSchema).default([]), moodEntries: z.array(moodEntrySchema).default([]), + // v1.15.0 — cycle-tracking tables. Default to empty arrays / null so a + // pre-v1.15 backup (no cycle keys) still round-trips unchanged. + cycleProfile: cycleProfileSchema.nullable().default(null), + cycles: z.array(cycleSpanSchema).default([]), + cycleDayLogs: z.array(cycleDayLogSchema).default([]), }) .passthrough(); @@ -140,6 +208,10 @@ export interface BackupSummary { medications: number; intakeEvents: number; moodEntries: number; + /** v1.15.0 — observed cycle spans in the backup. */ + cycles: number; + /** v1.15.0 — cycle day-logs in the backup. */ + cycleDayLogs: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -151,6 +223,8 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { medications: payload.medications.length, intakeEvents: payload.intakeEvents.length, moodEntries: payload.moodEntries.length, + cycles: payload.cycles.length, + cycleDayLogs: payload.cycleDayLogs.length, }; } diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts index 4ddd9503e..079f39475 100644 --- a/src/lib/validations/doctor-report-prefs.ts +++ b/src/lib/validations/doctor-report-prefs.ts @@ -31,6 +31,7 @@ export const doctorReportPrefsSchema = z mood: z.boolean(), compliance: z.boolean(), sleep: z.boolean(), + cycle: z.boolean(), }) .partial(); @@ -49,6 +50,7 @@ export interface DoctorReportPrefs { mood: boolean; compliance: boolean; sleep: boolean; + cycle: boolean; } /** @@ -65,6 +67,9 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { mood: false, // privacy default per Marc compliance: true, sleep: true, + // Cycle data is opt-in: a user sharing a BP report with a cardiologist + // should not auto-leak reproductive data. Same privacy stance as mood. + cycle: false, }; /** diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index ca76980fa..d453f785e 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -72,6 +72,8 @@ export const exportSectionsSchema = z medications: medicationsGroupSchema, mood: z.boolean(), bmi: z.boolean(), + // Cycle / reproductive health — opt-in (privacy default OFF, like mood). + cycle: z.boolean(), }) .partial(); @@ -131,5 +133,7 @@ export function toDoctorReportPrefs( mood: s.mood === true, compliance: m.compliance ?? m.list ?? fallback.compliance, sleep: a.sleep ?? fallback.sleep, + // Cycle is opt-in: only true when explicitly requested (privacy). + cycle: s.cycle === true, }; } diff --git a/tests/integration/cycle-import-export.test.ts b/tests/integration/cycle-import-export.test.ts new file mode 100644 index 000000000..288ea873a --- /dev/null +++ b/tests/integration/cycle-import-export.test.ts @@ -0,0 +1,230 @@ +/** + * Integration suite for the v1.15.0 cycle import-promotion (wave 4) and the + * cycle export inclusion (wave 9) against a real Postgres testcontainer. + * + * - the Apple Health `export.xml` importer routes reproductive HK samples + * into CYCLE day-logs (NOT Measurement), gated on cycle-tracking enabled, + * merging same-day samples into one row with first-write-wins re-import + * - the full-backup cycle section round-trips through + * `buildCycleBackupSection` → `restoreCycleData` with `notesEncrypted` + * preserved verbatim and symptom links re-resolved by key + */ +import { writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export"; +import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup"; +import { parseBackupPayload } from "@/lib/validations/backup"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const USER_ID = "user-cycle-import"; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER_ID, + username: "cycle-import", + email: "cycle-import@example.test", + gender: "FEMALE", + timezone: "Europe/Berlin", + }, + }); + // Ensure a catalogue symptom exists for the link re-resolution (the + // TRUNCATE CASCADE may wipe the migration-seeded catalogue). + await prisma.cycleSymptomCategory.upsert({ + where: { key: "physical" }, + update: {}, + create: { key: "physical", labelKey: "cycle.symptomCategory.physical" }, + }); + const category = await prisma.cycleSymptomCategory.findUniqueOrThrow({ + where: { key: "physical" }, + }); + await prisma.cycleSymptom.upsert({ + where: { key: "cramps" }, + update: {}, + create: { + key: "cramps", + labelKey: "cycle.symptom.cramps", + categoryId: category.id, + }, + }); +}); + +function writeXml(records: string): string { + const dir = mkdtempSync(join(tmpdir(), "hl-cycle-xml-")); + const path = join(dir, "export.xml"); + writeFileSync( + path, + `\n\n${records}\n`, + ); + return path; +} + +describe("cycle import — reproductive HK samples route to CycleDayLog", () => { + it("folds same-day flow + mucus + cramps into one day-log, not Measurement", async () => { + const prisma = getPrismaClient(); + const xml = writeXml( + [ + ``, + ``, + ``, + ].join("\n"), + ); + + const result = await streamParseExportXml({ + xmlPath: xml, + userId: USER_ID, + userTimezone: "Europe/Berlin", + prisma, + }); + + expect(result.cycle.samplesConsumed).toBe(3); + expect(result.cycle.daysUpserted).toBe(1); + expect(result.cycle.daysInserted).toBe(1); + + const dayLogs = await prisma.cycleDayLog.findMany({ + where: { userId: USER_ID }, + include: { symptomLinks: { include: { symptom: true } } }, + }); + expect(dayLogs).toHaveLength(1); + expect(dayLogs[0].date).toBe("2026-03-02"); + expect(dayLogs[0].flow).toBe("MEDIUM"); + expect(dayLogs[0].cervicalMucus).toBe("EGG_WHITE"); + expect(dayLogs[0].source).toBe("APPLE_HEALTH"); + expect(dayLogs[0].symptomLinks.map((l) => l.symptom.key)).toContain( + "cramps", + ); + + // No reproductive Measurement rows were created. + const measurements = await prisma.measurement.count({ + where: { userId: USER_ID }, + }); + expect(measurements).toBe(0); + }); + + it("is idempotent on re-import (first-write-wins on the synthetic day key)", async () => { + const prisma = getPrismaClient(); + const records = ``; + const xml = writeXml(records); + + await streamParseExportXml({ + xmlPath: xml, + userId: USER_ID, + userTimezone: "Europe/Berlin", + prisma, + }); + const second = await streamParseExportXml({ + xmlPath: writeXml(records), + userId: USER_ID, + userTimezone: "Europe/Berlin", + prisma, + }); + + expect(second.cycle.daysUpserted).toBe(1); + expect(second.cycle.daysInserted).toBe(0); // already existed + const count = await prisma.cycleDayLog.count({ where: { userId: USER_ID } }); + expect(count).toBe(1); + }); + + it("skips the cycle fold entirely for a non-cycle account", async () => { + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: USER_ID }, + data: { gender: "MALE" }, + }); + const xml = writeXml( + ``, + ); + + const result = await streamParseExportXml({ + xmlPath: xml, + userId: USER_ID, + userTimezone: "Europe/Berlin", + prisma, + }); + + // Sample was bucketed but the gate suppressed the flush. + expect(result.cycle.daysUpserted).toBe(0); + const count = await prisma.cycleDayLog.count({ where: { userId: USER_ID } }); + expect(count).toBe(0); + }); +}); + +describe("cycle backup — round-trips through build + restore", () => { + it("preserves notesEncrypted ciphertext and symptom links by key", async () => { + const prisma = getPrismaClient(); + + const cycle = await prisma.menstrualCycle.create({ + data: { + userId: USER_ID, + startDate: "2026-03-01", + endDate: "2026-03-28", + periodEndDate: "2026-03-05", + lengthDays: 28, + }, + }); + const dayLog = await prisma.cycleDayLog.create({ + data: { + userId: USER_ID, + date: "2026-03-02", + flow: "MEDIUM", + cycleId: cycle.id, + notesEncrypted: "v1:abc123:cipher-envelope", + source: "MANUAL", + }, + }); + const symptom = await prisma.cycleSymptom.findUniqueOrThrow({ + where: { key: "cramps" }, + }); + await prisma.cycleSymptomLink.create({ + data: { dayLogId: dayLog.id, symptomId: symptom.id }, + }); + await prisma.cycleProfile.create({ + data: { userId: USER_ID, goal: "TRYING_TO_CONCEIVE", cycleTrackingEnabled: true }, + }); + + // Build the backup section, then assert it round-trips through the schema. + const section = await buildCycleBackupSection(prisma, USER_ID); + expect(section.cycles).toHaveLength(1); + expect(section.cycleDayLogs).toHaveLength(1); + expect(section.cycleDayLogs[0].notesEncrypted).toBe( + "v1:abc123:cipher-envelope", + ); + expect(section.cycleDayLogs[0].symptomKeys).toEqual(["cramps"]); + + const payload = parseBackupPayload({ + schemaVersion: "1", + exportedAt: new Date().toISOString(), + userId: USER_ID, + ...section, + }); + + // Restore into a clean transaction (wipes then recreates). + await prisma.$transaction(async (tx) => { + await restoreCycleData(tx, USER_ID, payload); + }); + + const restored = await prisma.cycleDayLog.findMany({ + where: { userId: USER_ID }, + include: { symptomLinks: { include: { symptom: true } } }, + }); + expect(restored).toHaveLength(1); + expect(restored[0].notesEncrypted).toBe("v1:abc123:cipher-envelope"); + expect(restored[0].cycleId).not.toBeNull(); + expect(restored[0].symptomLinks.map((l) => l.symptom.key)).toEqual([ + "cramps", + ]); + + const profile = await prisma.cycleProfile.findUniqueOrThrow({ + where: { userId: USER_ID }, + }); + expect(profile.goal).toBe("TRYING_TO_CONCEIVE"); + }); +});