diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a4daf39..902c31222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## [Unreleased] +## [1.32.35] — 2026-07-25 + +The doctor report stops asking for your practice name every time. + +You type a clinic or practice name onto the cover of a health record export, +and until now that was all it did. The field emptied itself again the moment +you left the panel, so next month's report started from a blank line. The name +you used last time is now kept and filled in for you. Clearing the line works +the same way and is remembered too, so a cover without a practice line stays +without one. The name is saved only once the report has actually been +produced, which means an export that fails partway leaves the previous value +alone. + +A backup now carries the origin of your daily totals. Steps, distance and +similar figures that arrive from Apple Health as one number per day are stored +with a note of where that number came from, so a rough estimate read out of an +archive can never overwrite a total the phone measured itself. That note was +missing from the backup file, so a restore brought the figures back without it +and the protection went quiet. It rides the backup now. Restoring an older +backup written before this fix marks the affected daily totals as unclassified +instead of leaving them ambiguous, which is the state a later sync knows how +to repair. + +Migration 0272 drops two columns that were written and never read. They held a +contributor count and a source fingerprint for archive derived daily totals, +and no screen, export or repair path has ever looked at either. The figures +themselves and the origin note above are untouched. + ## [1.32.34] — 2026-07-25 Housekeeping on the interface. Most of it you will not see. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 289e03009..e9884298b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.34 + version: 1.32.35 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index e73c58a5f..997bd607c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.34", + "version": "1.32.35", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0272_drop_write_only_aggregation_columns/migration.sql b/prisma/migrations/0272_drop_write_only_aggregation_columns/migration.sql new file mode 100644 index 000000000..2f81a3122 --- /dev/null +++ b/prisma/migrations/0272_drop_write_only_aggregation_columns/migration.sql @@ -0,0 +1,36 @@ +-- v1.32.35 — drop the two write-only aggregation columns on `measurements`. +-- +-- Migration 0263 added three columns alongside the Apple Health `stats:*` +-- authority ladder. Only one of them was ever read back: +-- +-- * `aggregation_provenance` is load-bearing and STAYS. The external +-- measurement reconciler selects it and refuses a lower-authority write +-- against a higher-authority canonical row, which is what keeps an +-- export.xml source-day estimate from clobbering a native HealthKit +-- statistic. The cumulative drain reads it too. +-- * `aggregation_contributor_count` and `aggregation_selected_source_hash` +-- are written and never read. Three call sites write them (the batch +-- ingest and the cumulative drain write explicit NULLs, the export.xml +-- import writes real values); nothing selects them, no raw SQL names +-- them, they reach no export, no backup, no API contract and no repair +-- path. Verified by a repo-wide search over every tracked file for both +-- the Prisma field names and the column names. +-- +-- The import-time selection algorithm keeps its deterministic tiebreak: the +-- source hash it compares is an in-memory map key computed per flush, not a +-- value read back from this table. Only the persisted copies go. +-- +-- DATA BOUNDARY — read before editing. +-- +-- Both columns are plain nullable scalars added by 0263 with no index, no +-- unique, no check and no foreign key, so `DROP COLUMN` rewrites the tuples of +-- `measurements` and stops there. `DROP COLUMN` never cascades into another +-- table on its own, and there is no CASCADE keyword here. No measured value, +-- timestamp, unit, note or identity field is touched, and the documented +-- LEGACY_UNKNOWN repair path operates on `aggregation_provenance`, which +-- survives. In Postgres this is a catalog-only operation, so it is cheap even +-- on the largest table in the schema. + +ALTER TABLE "measurements" + DROP COLUMN IF EXISTS "aggregation_contributor_count", + DROP COLUMN IF EXISTS "aggregation_selected_source_hash"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2a4920eca..e2d69c31f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1636,13 +1636,7 @@ model Measurement { // source-day estimate derived from export.xml; migrated ambiguous rows are // explicitly LEGACY_UNKNOWN until a native sync or archive reprocess repairs // them. - aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance") - // Distinct source identities contributing to an export.xml source-day - // estimate. Source labels themselves are never persisted. - aggregationContributorCount Int? @map("aggregation_contributor_count") - // SHA-256 of the deterministically selected source identity. Non-identifying - // provenance for repeatability without retaining device/source labels. - aggregationSelectedSourceHash String? @map("aggregation_selected_source_hash") + aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance") // Only set for BLOOD_GLUCOSE measurements. NULL for all others. glucoseContext GlucoseContext? @map("glucose_context") // v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL diff --git a/public/sw.js b/public/sw.js index c42b23c88..8978d4ac0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.34"; + /* @sw-version-fallback */ "v1.32.35"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index d417d0568..879a0a42e 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -407,6 +407,25 @@ const handler = apiHandler( : decodeEncryptedBytes(measurement.notesEncrypted), externalId: measurement.externalId ?? null, externalSourceVersion: measurement.externalSourceVersion ?? null, + // Authority of an Apple Health `stats:` aggregate. A backup + // written before this field rode the payload carries nothing, and + // restoring NULL would flatten the authority ladder: an export.xml + // source-day estimate could then overwrite a native HealthKit + // statistic, which is the ambiguity migration 0263 already + // repaired once. Such a row is exactly the case LEGACY_UNKNOWN + // exists for, so it comes back repairable instead of ambiguous. + // + // The gate is scoped exactly as 0263 scoped its backfill and as + // the cumulative drain scopes its own LEGACY_UNKNOWN writes: + // Apple Health rows only. Other providers mint `stats:` ids for + // their cumulative dailies too (Fitbit, Google Health) and have + // never carried provenance — marking those would invent a state + // they were never in. Ordinary point rows keep NULL honestly. + aggregationProvenance: (measurement.aggregationProvenance ?? + (measurement.source === "APPLE_HEALTH" && + measurement.externalId?.startsWith("stats:") + ? "LEGACY_UNKNOWN" + : null)) as never, glucoseContext: (measurement.glucoseContext ?? null) as never, sleepStage: (measurement.sleepStage ?? null) as never, rhythmClassification: (measurement.rhythmClassification ?? diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 08ad8e15b..2edf0ab3c 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -249,3 +249,76 @@ describe("POST /api/export/health-record — outputs", () => { ); }); }); + +/** + * The practice name is a remembered preference (`User.lastReportPracticeName`), + * so a monthly report does not ask for the clinic name again every time. The + * write happens only once the artefact exists — a generation that throws must + * not overwrite the remembered value with a half-typed one. + * + * Mutation check: drop the `rememberPracticeName` call in the route and the + * first two cases go red; move it above the generation and the + * failed-generation case goes red. + */ +describe("POST /api/export/health-record — remembered practice name", () => { + it("persists the sanitised practice name after a PDF export", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq({ format: "pdf", practiceName: " Sample Practice " }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { lastReportPracticeName: "Sample Practice" }, + }); + }); + + it("persists the practice name after a FHIR export", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq({ format: "fhir", practiceName: "Sample Practice" }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { lastReportPracticeName: "Sample Practice" }, + }); + }); + + it("persists null when the user cleared the practice name", async () => { + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "pdf" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { lastReportPracticeName: null }, + }); + }); + + it("writes nothing when the selection is rejected", async () => { + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "xml", practiceName: "Sample" })); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("writes nothing when the generation fails", async () => { + vi.mocked(prisma.measurement.findMany).mockRejectedValue( + new Error("read failed"), + ); + const { POST } = await import("../route"); + const res = await POST( + mkReq({ format: "pdf", practiceName: "Sample Practice" }), + ); + expect(res.status).toBe(500); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("narrows the update to the session user, never the body", async () => { + const { POST } = await import("../route"); + await POST(mkReq({ format: "fhir", practiceName: "Sample Practice" })); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "user-1" } }), + ); + }); +}); diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 83253d1ff..adde3d743 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -54,6 +54,28 @@ import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; import { locales, type Locale } from "@/lib/i18n/config"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +/** + * Remember the practice / clinic name for the next report, so the user does + * not retype it every time (`User.lastReportPracticeName`, the preference the + * doctor-report contract has always promised). + * + * Called only once the requested artefact has actually been produced: a + * generation that throws must not overwrite the remembered name with a + * half-typed one. `null` is a legitimate value — the user cleared the line and + * the next cover should stay cleared. The value is the already-sanitised one + * (`sanitisePracticeName`, 120-char cap), and the update names its single + * field explicitly rather than spreading the parsed selection. + */ +async function rememberPracticeName( + userId: string, + practiceName: string | null, +) { + await prisma.user.update({ + where: { id: userId }, + data: { lastReportPracticeName: practiceName }, + }); +} + export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "export.health-record.build" } }); @@ -189,6 +211,7 @@ export const POST = apiHandler(async (request: NextRequest) => { fhirRecords, ); const json = JSON.stringify(bundle); + await rememberPracticeName(user.id, practiceName); annotate({ meta: { format: "fhir", bytes: json.length, days: range.days }, }); @@ -212,6 +235,9 @@ export const POST = apiHandler(async (request: NextRequest) => { includeCharts, aiSummary, }); + // Both remaining formats (`pdf`, `package`) carry the rendered PDF, so the + // artefact exists from here on. + await rememberPracticeName(user.id, practiceName); if (selection.format === "pdf") { annotate({ diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 1fe4aee66..8aa5ec5db 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -544,8 +544,6 @@ async function postBatch(request: NextRequest): Promise { .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], deviceType: p.row.deviceType as string | null, aggregationProvenance: statsRow ? "HEALTHKIT_STATISTICS" : null, - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }, { exactExternalMatch: statsRow ? "update" : "duplicate" }, ), diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index 945933485..b6b326e09 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -74,6 +74,7 @@ function buildUser(disableCoach: boolean): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/components/settings/__tests__/health-record-export-practice-name.test.tsx b/src/components/settings/__tests__/health-record-export-practice-name.test.tsx new file mode 100644 index 000000000..24c5c298e --- /dev/null +++ b/src/components/settings/__tests__/health-record-export-practice-name.test.tsx @@ -0,0 +1,97 @@ +/** + * Settings → Gesundheitsakte: the practice name is remembered between reports. + * + * `User.lastReportPracticeName` is persisted by the export route after a + * successful generation and rides the `/api/auth/me` payload. This suite pins + * the read half: the panel opens pre-filled with the last-used name instead of + * asking for the clinic name again on every report. + * + * SSR-only, per the project convention for this surface (node environment, + * no `@testing-library/react`). The seed runs during render — a `useState` + * initializer would read `undefined` while `useAuth` is still resolving and + * stick at empty — so the static markup already carries the seeded value. + * + * Mutation check: revert the seeding block in the panel and the first case + * goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { AuthUser } from "@/hooks/use-auth"; + +const authSpy = vi.fn<() => { user: AuthUser | null }>(); +vi.mock("@/hooks/use-auth", async () => { + const actual = + await vi.importActual( + "@/hooks/use-auth", + ); + return { ...actual, useAuth: () => authSpy() }; +}); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { HealthRecordExportPanel } from "../health-record-export-panel"; + +function buildUser(lastReportPracticeName: string | null): AuthUser { + return { + id: "user-1", + username: "user", + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + onboardingCompletedAt: null, + onboardingTourCompleted: true, + onboardingTourProgress: null, + avatarUrl: null, + glucoseUnit: null, + unitPreference: "metric", + timeFormat: "AUTO", + dateFormat: "AUTO", + disableCoach: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumber: null, + lastReportPracticeName, + cycleTrackingEnabled: false, + modules: {}, + } as AuthUser; +} + +function renderPanel(user: AuthUser | null): string { + authSpy.mockReturnValue({ user }); + return renderToStaticMarkup( + + + , + ); +} + +function practiceInputValue(html: string): string | null { + const input = html.match(/]*id="hr-practice"[^>]*>/)?.[0]; + if (!input) return null; + return input.match(/value="([^"]*)"/)?.[1] ?? ""; +} + +describe(" — remembered practice name", () => { + it("pre-fills the input with the last-used practice name", () => { + const html = renderPanel(buildUser("Sample Practice")); + expect(practiceInputValue(html)).toBe("Sample Practice"); + }); + + it("leaves the input empty when no name was ever remembered", () => { + const html = renderPanel(buildUser(null)); + expect(practiceInputValue(html)).toBe(""); + }); + + it("leaves the input empty while the auth payload is still resolving", () => { + const html = renderPanel(null); + expect(practiceInputValue(html)).toBe(""); + }); +}); diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index 88c93e4d1..a77475675 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -107,6 +107,7 @@ function buildUser( insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules, moduleAvailability: {}, diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index cea538c04..27c89a7c8 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -52,6 +52,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index e944c2ddc..9fc1945dd 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -55,6 +55,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index 385efb790..70bc7d956 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -22,6 +22,7 @@ import Link from "next/link"; import { useId, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Download, @@ -41,6 +42,7 @@ import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; type ExportFormat = "pdf" | "fhir" | "package"; @@ -126,9 +128,13 @@ function buildSelectionSections(s: SectionState) { export function HealthRecordExportPanel() { const { t, locale } = useTranslations(); const { user } = useAuth(); + const queryClient = useQueryClient(); const [format, setFormat] = useState("pdf"); const [days, setDays] = useState(90); const [practiceName, setPracticeName] = useState(""); + const [practiceNameSeededUserId, setPracticeNameSeededUserId] = useState< + string | null + >(null); const [includeCharts, setIncludeCharts] = useState(true); const [includeAiSummary, setIncludeAiSummary] = useState(false); const [sections, setSections] = useState(DEFAULT_SECTIONS); @@ -139,6 +145,17 @@ export function HealthRecordExportPanel() { const [includedDataOpen, setIncludedDataOpen] = useState(false); const includedDataPanelId = useId(); + // Seed the practice name from the last export once the auth payload lands. + // `useAuth` resolves asynchronously, so a `useState` initializer would read + // `undefined` and stick at empty. Store-the-id-alongside-the-state (the same + // pattern the account form uses) syncs during render instead of in an + // effect, and the per-user gate means a late re-resolve never clobbers a + // field the user is already typing into. + if (user && user.id !== practiceNameSeededUserId) { + setPracticeNameSeededUserId(user.id); + setPracticeName(user.lastReportPracticeName ?? ""); + } + const isPdfLike = format === "pdf" || format === "package"; const isFhirLike = format === "fhir" || format === "package"; @@ -199,6 +216,10 @@ export function HealthRecordExportPanel() { a.click(); a.remove(); URL.revokeObjectURL(url); + // The route just persisted the practice name, so the cached `/me` + // payload is stale. Refresh it, or a remount of this panel would seed + // from the previous value. + void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 537ed1dac..af8208231 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -116,6 +116,13 @@ export interface AuthUser { */ insurerIkNumber: string | null; insuranceNumber: string | null; + /** + * Practice / clinic name from the last health-record export, so the export + * panel opens pre-filled instead of asking for it again every month. The + * export route persists it after a successful generation; `null` means the + * user has never set one, or deliberately cleared the line. + */ + lastReportPracticeName: string | null; /** * v1.15.0 — cycle-tracking feature gate, resolved server-side from gender * + the per-user opt-in. The cycle nav entry + page hide when false; every diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 82170b68e..aac92b01f 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -204,6 +204,7 @@ function installSectionMocks() { notesEncrypted: measurementNote, externalId: "oura-stage-deep", externalSourceVersion: "oura-v3", + aggregationProvenance: "HEALTHKIT_STATISTICS", glucoseContext: null, sleepStage: "DEEP", rhythmClassification: null, @@ -297,6 +298,7 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { notesEncrypted: Buffer.from(measurementNote).toString("base64"), externalId: "oura-stage-deep", externalSourceVersion: "oura-v3", + aggregationProvenance: "HEALTHKIT_STATISTICS", glucoseContext: null, sleepStage: "DEEP", rhythmClassification: null, @@ -371,3 +373,72 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { ).rejects.toBe(failure); }); }); + +/** + * `aggregationProvenance` is what stops an export.xml source-day estimate from + * overwriting a native HealthKit statistic. A disaster-recovery backup that + * drops it restores a flattened authority ladder, so it rides the select and + * the disaster-recovery serialization the same way `externalSourceVersion` + * does — and stays out of the reduced portable shape, also like + * `externalSourceVersion`. + * + * Mutation check: drop `aggregationProvenance: true` from the select, or drop + * the field from the disaster-recovery arm, and the first two cases go red. + */ +describe("buildFullBackupPayload — aggregation provenance", () => { + it("selects the provenance column", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + await buildFullBackupPayload(prisma as never, "user-1", { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }); + + expect(mocks.iterateMeasurementPages).toHaveBeenCalledWith( + prisma, + expect.anything(), + expect.objectContaining({ aggregationProvenance: true }), + ); + }); + + it("carries the provenance into the disaster-recovery payload", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const rows = payload.measurements as Array>; + expect(rows[0]).toHaveProperty( + "aggregationProvenance", + "HEALTHKIT_STATISTICS", + ); + }); + + it("leaves the provenance out of the reduced portable shape", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "portable-export", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const rows = payload.measurements as Array>; + // The portable arm carries the human-readable subset only — no + // externalSourceVersion, no glucoseContext, and no provenance either. + expect(rows[0]).not.toHaveProperty("aggregationProvenance"); + expect(rows[0]).not.toHaveProperty("externalSourceVersion"); + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 5e870a023..e0c813eb5 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -83,6 +83,7 @@ export async function buildFullBackupPayload( notesEncrypted: true, externalId: true, externalSourceVersion: true, + aggregationProvenance: true, glucoseContext: true, sleepStage: true, rhythmClassification: true, @@ -112,6 +113,7 @@ export async function buildFullBackupPayload( : null, externalId: measurement.externalId, externalSourceVersion: measurement.externalSourceVersion, + aggregationProvenance: measurement.aggregationProvenance, glucoseContext: measurement.glucoseContext, sleepStage: measurement.sleepStage, rhythmClassification: measurement.rhythmClassification, diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index c85a9a275..4dc6553aa 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -101,6 +101,7 @@ function buildUser(disableCoach: boolean): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index 389ba7d41..030f94be0 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -652,11 +652,7 @@ describe("streamParseExportXml — cumulative source-day estimates", () => { expect(prisma._measurements[0]).toMatchObject({ value: 8_526, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, }); - expect(prisma._measurements[0]?.aggregationSelectedSourceHash).toMatch( - /^[a-f0-9]{64}$/, - ); expect(result.cumulativeEstimates).toEqual({ days: 1, rows: 1 }); }); @@ -675,7 +671,6 @@ describe("streamParseExportXml — cumulative source-day estimates", () => { type: mapping.measurementType, value: 30, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, }); }, ); @@ -694,7 +689,6 @@ describe("streamParseExportXml — cumulative source-day estimates", () => { expect(prisma._measurements[0]).toMatchObject({ type: mapping.measurementType, value: 50, - aggregationContributorCount: 1, }); }, ); @@ -712,7 +706,6 @@ describe("streamParseExportXml — cumulative source-day estimates", () => { expect(prisma._measurements).toHaveLength(1); expect(prisma._measurements[0]).toMatchObject({ value: 100, - aggregationContributorCount: 1, }); }); @@ -1171,7 +1164,6 @@ describe("streamParseExportXml — memory ceiling", () => { expect(prisma._measurements).toHaveLength(1); expect(prisma._measurements[0]).toMatchObject({ value: 10_000, - aggregationContributorCount: 1, }); // Heap delta should be modest — we tolerate a generous 100 MB so a diff --git a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts index 1a755dc72..5a2d012fd 100644 --- a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts +++ b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts @@ -40,8 +40,6 @@ function row(overrides: Record = {}): Record { externalId: "sleep:new", externalSourceVersion: null, aggregationProvenance: null, - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, glucoseContext: null, sleepStage: "DEEP", rhythmClassification: null, @@ -224,8 +222,6 @@ describe("reconcileExternalMeasurement", () => { sleepStage: null, externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }); const { tx, measurement } = transaction([native]); @@ -240,8 +236,6 @@ describe("reconcileExternalMeasurement", () => { sleepStage: null, externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, - aggregationSelectedSourceHash: "source-hash", }), { exactExternalMatch: "update" }, ); @@ -350,8 +344,6 @@ describe("reconcileExternalMeasurement", () => { sleepStage: null, externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }), { exactExternalMatch: "update" }, ); @@ -366,8 +358,6 @@ describe("reconcileExternalMeasurement", () => { data: expect.objectContaining({ value: 8_600, aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, syncVersion: { increment: 1 }, }), }), diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index 9fb503f77..81973acc7 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -352,8 +352,6 @@ export async function drainPerSampleCumulative( externalId, deletedAt: null, aggregationProvenance: "LEGACY_UNKNOWN", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, syncVersion: { increment: 1 }, ...(slotIsFreeForTarget ? { measuredAt: canonicalTimestamp } diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 030cd5b10..320e9c46f 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -680,7 +680,10 @@ export async function streamParseExportXml( } } if (!selected) continue; - const [selectedSourceHash, selectedSubtotal] = selected; + // Only the subtotal is carried forward. The source hash stays an + // in-memory map key — it is what makes the max-subtotal selection + // above deterministic on a tie, and it is not persisted. + const [, selectedSubtotal] = selected; if (validateMeasurementRange(type, selectedSubtotal) !== null) { unknown[`${type}::aggregate_out_of_range`] = (unknown[`${type}::aggregate_out_of_range`] ?? 0) + 1; @@ -704,8 +707,6 @@ export async function streamParseExportXml( sleepStage: null, deviceType: null, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: bySource.size, - aggregationSelectedSourceHash: selectedSourceHash, }, { exactExternalMatch: "update" }, ), diff --git a/src/lib/onboarding/goals.ts b/src/lib/onboarding/goals.ts index 3a9655502..bf5385d91 100644 --- a/src/lib/onboarding/goals.ts +++ b/src/lib/onboarding/goals.ts @@ -6,9 +6,11 @@ * personalization signal: which dashboard tiles get promoted to the * top and made visible. Goals are NOT a clinical target store — the * targets surface (`/api/insights/targets`) is fully derived from - * height/age/gender + clinical references. Their only leverage is - * (a) dashboard tile pinning (here) and (b) reminder suggestions - * (read by the reminders panel from `User.onboardingGoals`). + * height/age/gender + clinical references. Their only leverage is the + * one-time dashboard tile pinning below, applied by + * `/api/onboarding/step` while the layout is still unset. Nothing else + * reads `User.onboardingGoals` — the column is collected, seeded from + * once, and then inert. * * One home for the slug enum so the API validation * (`/api/onboarding/step`), the client picker, and the seeding all diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 326bec119..854b1a7be 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -43,6 +43,7 @@ import { InjectionSite, IntakeAttributionSource, IntakeSource, + MeasurementAggregationProvenance, MeasurementSource, MeasurementType, MedicationCategory, @@ -86,6 +87,12 @@ const measurementSchema = z notesEncrypted: base64BytesSchema.nullable().optional(), externalId: z.string().nullable().optional(), externalSourceVersion: z.string().nullable().optional(), + // Authority of a `stats:` aggregate. `.optional()` keeps every backup + // written before this field rode the payload parseable. + aggregationProvenance: z + .enum(MeasurementAggregationProvenance) + .nullable() + .optional(), glucoseContext: z.enum(GlucoseContext).nullable().optional(), sleepStage: z.enum(SleepStage).nullable().optional(), rhythmClassification: z.enum(RhythmClassification).nullable().optional(), diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 506fad988..cfd86f30e 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -1130,4 +1130,142 @@ describe("canonical disaster-recovery backup round-trip", () => { ); expect(invalidateUserData).not.toHaveBeenCalled(); }); + + /** + * `aggregationProvenance` is what keeps an export.xml source-day estimate + * from overwriting a native HealthKit statistic. A backup that drops it + * restores a flattened authority ladder, so it has to survive the round + * trip — and a backup written before it rode the payload has to land in the + * documented repairable state rather than reproduce the ambiguity migration + * 0263 already repaired once. + * + * Mutation check: drop `aggregationProvenance` from the backup select or + * from the restore mapping and the first case goes red; drop the + * Apple-Health `stats:` fallback and the second goes red. + */ + it("round-trips the aggregation provenance of a stats aggregate", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const payload = backupPayloadSchema.parse({ + schemaVersion: "2", + exportedAt: "2026-07-25T00:00:00.000Z", + userId: admin.id, + measurements: [ + { + id: "provenance-native", + type: "ACTIVITY_STEPS", + value: 8_600, + unit: "steps", + measuredAt: "2026-07-20T10:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", + aggregationProvenance: "HEALTHKIT_STATISTICS", + }, + { + id: "provenance-xml", + type: "ACTIVITY_STEPS", + value: 8_526, + unit: "steps", + measuredAt: "2026-07-21T10:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-21", + aggregationProvenance: "EXPORT_XML_SOURCE_MAX", + }, + ], + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "PROVENANCE_ROUNDTRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + expect( + await prisma.measurement.findMany({ + where: { userId: admin.id }, + orderBy: { measuredAt: "asc" }, + select: { id: true, aggregationProvenance: true }, + }), + ).toEqual([ + { + id: "provenance-native", + aggregationProvenance: "HEALTHKIT_STATISTICS", + }, + { id: "provenance-xml", aggregationProvenance: "EXPORT_XML_SOURCE_MAX" }, + ]); + }); + + it("marks an unlabelled Apple Health stats aggregate as legacy-unknown on restore", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + // A backup taken before the provenance rode the payload: no field at all. + const payload = backupPayloadSchema.parse({ + schemaVersion: "2", + exportedAt: "2026-07-25T00:00:00.000Z", + userId: admin.id, + measurements: [ + { + id: "legacy-apple-stats", + type: "ACTIVITY_STEPS", + value: 8_526, + unit: "steps", + measuredAt: "2026-07-20T10:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", + }, + { + // Another provider's cumulative daily. It mints a `stats:` id too but + // has never carried provenance, so inventing one would be a lie. + id: "legacy-fitbit-stats", + type: "ACTIVITY_STEPS", + value: 7_100, + unit: "steps", + measuredAt: "2026-07-21T10:00:00.000Z", + source: "FITBIT", + externalId: "stats:steps", + }, + { + // An ordinary point reading never had provenance either. + id: "legacy-point-row", + type: "PULSE", + value: 61, + unit: "bpm", + measuredAt: "2026-07-22T10:00:00.000Z", + source: "MANUAL", + }, + ], + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "PROVENANCE_LEGACY", + data: encrypt(JSON.stringify(payload)), + }, + }); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + expect( + await prisma.measurement.findMany({ + where: { userId: admin.id }, + orderBy: { measuredAt: "asc" }, + select: { id: true, aggregationProvenance: true }, + }), + ).toEqual([ + { id: "legacy-apple-stats", aggregationProvenance: "LEGACY_UNKNOWN" }, + { id: "legacy-fitbit-stats", aggregationProvenance: null }, + { id: "legacy-point-row", aggregationProvenance: null }, + ]); + }); }); diff --git a/tests/integration/measurement-identity-reconcile.test.ts b/tests/integration/measurement-identity-reconcile.test.ts index 9ebfa33ba..21ebecc12 100644 --- a/tests/integration/measurement-identity-reconcile.test.ts +++ b/tests/integration/measurement-identity-reconcile.test.ts @@ -114,8 +114,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => { measuredAt: new Date("2026-07-20T09:00:00.000Z"), sleepStage: null, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, - aggregationSelectedSourceHash: "xml-source-hash", }, ), }, @@ -132,8 +130,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => { externalSourceVersion: "native-v1", deviceType: "Apple Watch", aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }), }, }); @@ -147,8 +143,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => { measuredAt: nativeMeasuredAt, sleepStage: null, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, - aggregationSelectedSourceHash: "xml-source-hash", }), { exactExternalMatch: "update" }, ); @@ -171,8 +165,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => { externalSourceVersion: "native-v1", deviceType: "Apple Watch", aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }); expect( await prisma.measurement.findUniqueOrThrow({ @@ -194,8 +186,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => { externalSourceVersion: "native-v2", deviceType: "Apple Watch", aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }), { exactExternalMatch: "update" }, ); diff --git a/tests/integration/measurements-batch.test.ts b/tests/integration/measurements-batch.test.ts index d0e4c9ee7..1617bbc5f 100644 --- a/tests/integration/measurements-batch.test.ts +++ b/tests/integration/measurements-batch.test.ts @@ -601,8 +601,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(stored[0]!.value).toBeCloseTo(5200, 5); expect(stored[0]).toMatchObject({ aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, syncVersion: 2, }); }); @@ -1217,7 +1215,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(estimated[0]).toMatchObject({ value: 8_526, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, syncVersion: 1, }); @@ -1235,8 +1232,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(canonical[0]).toMatchObject({ value: 8_600, aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, syncVersion: 2, }); }); @@ -1297,7 +1292,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(rows[0]).toMatchObject({ value: 8_526, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, syncVersion: 2, }); });