mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
7333d8e40f
commit
e984ada5f1
39 files changed
+2406
-41
No files matched your search
@@ -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:<YYYY-MM-DD>`) 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. |
|
||||
@@ -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
|
||||
|
||||
+17
-1
@@ -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",
|
||||
|
||||
+17
-1
@@ -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",
|
||||
|
||||
+17
-1
@@ -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",
|
||||
|
||||
+17
-1
@@ -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",
|
||||
|
||||
+17
-1
@@ -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",
|
||||
|
||||
+17
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<Response>)(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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) }),
|
||||
);
|
||||
|
||||
@@ -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<keyof DoctorReportPrefs, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<CycleBackupSection> {
|
||||
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<T extends string>(
|
||||
value: string | null | undefined,
|
||||
allow: Set<string>,
|
||||
): 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<CycleRestoreCleared> {
|
||||
// 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<string, string>();
|
||||
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<string, string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<CycleExportSummary | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 `<MetadataEntry>` 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<string, FlowLevel> = {
|
||||
"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<string, OvulationTest> = {
|
||||
"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<string, CervicalMucus> = {
|
||||
"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<string, HomeTestResult> = {
|
||||
"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<string, ContraceptiveKind> = {
|
||||
"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<string, string> = {
|
||||
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<string> = new Set<string>([
|
||||
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" };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* `<Record>` 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:<YYYY-MM-DD>`), 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:<HKType>:<day>` 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<string>;
|
||||
/** 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 `<Record>`; `flush()`
|
||||
* once at the end.
|
||||
*/
|
||||
export class CycleImportAccumulator {
|
||||
private readonly byDay = new Map<string, DayBucket>();
|
||||
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<boolean> {
|
||||
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<CycleImportStats> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>): 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<DoctorReportData["cycle"]> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -308,3 +308,11 @@ export const GLUCOSE_LOINC: Record<string, { loinc: string; display: string }> =
|
||||
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";
|
||||
@@ -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<string, string> = {
|
||||
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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<DataBackupPayload>[]) {
|
||||
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<DataBackupPayload>[]) {
|
||||
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<DataBackupPayload>[]) {
|
||||
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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -890,13 +890,17 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
|
||||
// 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<string>([
|
||||
// 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",
|
||||
|
||||
@@ -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<string, number>;
|
||||
unknown: Record<string, number>;
|
||||
totals: {
|
||||
@@ -281,6 +293,16 @@ export async function streamParseExportXml(
|
||||
const deferred: Record<string, number> = {};
|
||||
const unknown: Record<string, number> = {};
|
||||
|
||||
// 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 `<Record>` whose protection metadata
|
||||
// arrives as a following `<MetadataEntry>` 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<MeasurementType, Map<string, number>>();
|
||||
// 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 `<MetadataEntry>` (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: {
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n<HealthData>\n${records}\n</HealthData>`,
|
||||
);
|
||||
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(
|
||||
[
|
||||
`<Record type="HKCategoryTypeIdentifierMenstrualFlow" value="HKCategoryValueMenstrualFlowMedium" startDate="2026-03-02 08:00:00 +0000" endDate="2026-03-02 08:00:00 +0000"/>`,
|
||||
`<Record type="HKCategoryTypeIdentifierCervicalMucusQuality" value="HKCategoryValueCervicalMucusQualityEggWhite" startDate="2026-03-02 09:00:00 +0000" endDate="2026-03-02 09:00:00 +0000"/>`,
|
||||
`<Record type="HKCategoryTypeIdentifierAbdominalCramps" value="3" startDate="2026-03-02 10:00:00 +0000" endDate="2026-03-02 10:00:00 +0000"/>`,
|
||||
].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 = `<Record type="HKCategoryTypeIdentifierMenstrualFlow" value="HKCategoryValueMenstrualFlowHeavy" startDate="2026-03-05 08:00:00 +0000" endDate="2026-03-05 08:00:00 +0000"/>`;
|
||||
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(
|
||||
`<Record type="HKCategoryTypeIdentifierMenstrualFlow" value="HKCategoryValueMenstrualFlowLight" startDate="2026-03-02 08:00:00 +0000" endDate="2026-03-02 08:00:00 +0000"/>`,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user