mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #638 from MBombeck/release/v1.32.35
release/v1.32.35 — remember the practice name, keep the source provenance in a backup
This commit is contained in:
29 files changed
+543
-54
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -544,8 +544,6 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
.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" },
|
||||
),
|
||||
|
||||
@@ -74,6 +74,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules: {},
|
||||
};
|
||||
|
||||
@@ -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<typeof import("@/hooks/use-auth")>(
|
||||
"@/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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthRecordExportPanel />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function practiceInputValue(html: string): string | null {
|
||||
const input = html.match(/<input[^>]*id="hr-practice"[^>]*>/)?.[0];
|
||||
if (!input) return null;
|
||||
return input.match(/value="([^"]*)"/)?.[1] ?? "";
|
||||
}
|
||||
|
||||
describe("<HealthRecordExportPanel> — 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("");
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,7 @@ function buildUser(
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules,
|
||||
moduleAvailability: {},
|
||||
|
||||
@@ -52,6 +52,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser {
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules: {},
|
||||
};
|
||||
|
||||
@@ -55,6 +55,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules: {},
|
||||
};
|
||||
|
||||
@@ -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<ExportFormat>("pdf");
|
||||
const [days, setDays] = useState<number>(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<SectionState>(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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -101,6 +101,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules: {},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,8 +40,6 @@ function row(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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 },
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -352,8 +352,6 @@ export async function drainPerSampleCumulative(
|
||||
externalId,
|
||||
deletedAt: null,
|
||||
aggregationProvenance: "LEGACY_UNKNOWN",
|
||||
aggregationContributorCount: null,
|
||||
aggregationSelectedSourceHash: null,
|
||||
syncVersion: { increment: 1 },
|
||||
...(slotIsFreeForTarget
|
||||
? { measuredAt: canonicalTimestamp }
|
||||
|
||||
@@ -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" },
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<typeof POST>[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<typeof POST>[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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user