diff --git a/prisma/migrations/0290_health_score_record/migration.sql b/prisma/migrations/0290_health_score_record/migration.sql new file mode 100644 index 000000000..6796babd6 --- /dev/null +++ b/prisma/migrations/0290_health_score_record/migration.sql @@ -0,0 +1,63 @@ +-- v1.35.0 — the health score as it was shown, one row per account per local day. +-- +-- Nothing can rebuild this. The live computation always answers for today from +-- today's rows, so once the readings behind a past day have been corrected, +-- re-synced or simply grown, the number the person saw on that day is gone +-- unless it was written down. This table is the writing down. +-- +-- Two properties are enforced here rather than left to the application: +-- +-- * one row per account per local day, so a second computation on the same +-- day cannot mint a rival record of the same day, and the writer's +-- ON CONFLICT DO NOTHING has something to conflict against; +-- * a closed set of bands, as a type rather than as a CHECK. The value the +-- application speaks is lowercase, so the enum carries the same labels and +-- needs no translation layer. A type also keeps the schema-driven wipe +-- seeder working: it reads `pg_enum` for a value it cannot otherwise +-- guess, where a CHECK would have forced a hand-written entry into a +-- fixture whose whole point is not having one. +-- +-- The composite is range-checked because it is free to check and a number +-- outside 0-100 would render as a score nobody can read. The day key's format +-- and the composition's non-emptiness are NOT checked here: the writer builds +-- the key through the timezone helper and refuses an empty composition before +-- it inserts, and the restore validates both against the wire schema before +-- the transaction opens. A third copy of those two rules would buy nothing. +-- +-- There is deliberately no updated_at column. The row is written once and +-- never rewritten: a later computation under different readings, or under a +-- different configuration, leaves the earlier day exactly as it stood. + +CREATE TYPE "health_score_band" AS ENUM ('green', 'yellow', 'red'); + +CREATE TABLE "health_score_records" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "day_key" VARCHAR(10) NOT NULL, + "timezone" TEXT NOT NULL, + "composite" INTEGER NOT NULL, + "band" "health_score_band" NOT NULL, + "score_version" INTEGER NOT NULL, + "composition" TEXT[] NOT NULL, + "pillar_scores" JSONB NOT NULL, + "input_fingerprint" VARCHAR(64) NOT NULL, + "config_version" INTEGER, + "config_changed_at" TIMESTAMPTZ(3), + "computed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "health_score_records_pkey" PRIMARY KEY ("id"), + CONSTRAINT "health_score_records_composite_range_check" + CHECK ("composite" >= 0 AND "composite" <= 100) +); + +CREATE UNIQUE INDEX "health_score_records_user_id_day_key_key" + ON "health_score_records" ("user_id", "day_key"); + +CREATE INDEX "health_score_records_user_id_day_key_desc_idx" + ON "health_score_records" ("user_id", "day_key" DESC); + +ALTER TABLE "health_score_records" + ADD CONSTRAINT "health_score_records_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b6fc2ec3..78d9faa1e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -870,6 +870,10 @@ model User { customMetrics CustomMetric[] customMetricEntries CustomMetricEntry[] correlationPatterns CorrelationPattern[] + // v1.35.0 — one row per local day holding the health score as it was shown + // that day. Append-only: a later recomputation of the same day never + // rewrites the row it finds. + healthScoreRecords HealthScoreRecord[] // v1.19.0 — illness / condition journal (born-gated by the module system). // IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the // per-day timeline mirroring the CycleDayLog shape. @@ -5074,6 +5078,92 @@ model UserAchievement { @@map("user_achievements") } +// ─── Health Score record ────── + +/// The three bands a composite can land in, lowercase because that is the +/// value every surface already speaks. Closed on purpose: the column refuses +/// a band no renderer knows. +enum HealthScoreBand { + green + yellow + red + + @@map("health_score_band") +} + +/// The health score as it was SHOWN to the account on one local day. +/// +/// Not a cache and not a derived tier: nothing can rebuild it. The live +/// computation always answers for today from today's rows, so once the rows +/// behind an old day have been edited, re-synced, corrected or simply grown, +/// the number the person actually saw that day is gone unless it was written +/// down. That is what this table is, and it is why the write is append-only: +/// the FIRST score computed on a local day is the one that is kept, and a +/// later computation of the same day leaves the row exactly as it found it. +/// +/// It never overrides the live computation either. Every surface keeps +/// reading the live score; this table is read only where a past day is the +/// question. +model HealthScoreRecord { + id String @id @default(cuid()) + userId String @map("user_id") + /// The LOCAL calendar day (`YYYY-MM-DD`) the score was computed for, cut in + /// `timezone`. Lexicographically ordered, so a trailing-window read is a + /// range scan. + dayKey String @map("day_key") @db.VarChar(10) + /// The IANA zone the day was cut in. An account that moves zones leaves + /// behind days cut on the old clock; carrying the zone is what lets a + /// reader see that rather than silently compare days of different lengths. + timezone String + + /// The composite 0-100 as shown. Integer, matching the composite's own + /// rounding. + composite Int + /// The band as shown. Stored rather than re-derived from `composite`, + /// because the worst pillar can pull the band below the one the mean alone + /// would give, so the number does not determine the band. + band HealthScoreBand + /// Algorithm identity the row was computed under. + scoreVersion Int @map("score_version") + /// Registry-ordered pillar ids that produced the composite. Part of the + /// number's identity: the same value under a different composition is a + /// different measurement. + composition String[] + /// Each counted pillar's own 0-100 score, keyed by pillar id. The composite + /// breaks across a composition change; these stay comparable through one, + /// which is the whole reason they are stored beside it rather than folded + /// away. + pillarScores Json @map("pillar_scores") + /// SHA-256 over exactly what the stored composite and band depend on: the + /// score version, the composition, and every counted pillar's score with + /// its scoring-mode identity. Two rows with the same fingerprint were + /// computed from the same standing; two rows that differ, were not. + inputFingerprint String @db.VarChar(64) @map("input_fingerprint") + + /// The account's health-score configuration version in force when the row + /// was written, and when that configuration last changed. The write path + /// takes both as parameters and persists whatever it is given; today + /// nothing supplies them, because the per-user configuration itself lands + /// beside this table and the guard that reads them lands after it. They are + /// here now so that guard does not need a second migration over a table + /// that will already hold rows. + /// @pending-consumer(score delta guard) suppresses the week-over-week delta across a configuration change, so a settings action is never narrated as a health event. + configVersion Int? @map("config_version") + /// @pending-consumer(score delta guard) dates the series break on the first record written after a configuration change. + configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3) + + /// When the score was computed. Distinct from `dayKey`: the day says which + /// day was scored, this says when the scoring ran. + computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, dayKey]) + @@index([userId, dayKey(sort: Desc)]) + @@map("health_score_records") +} + // ─── Host Metrics (admin system-status host-load chart) ────── /// Per-minute snapshot of host-process load + memory captured by the diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 228c20474..1305d3123 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -257,6 +257,29 @@ function sourceClient() { }, ]), }, + // The score as it was SHOWN on a local day. Not recomputable: today's + // number always comes from today's rows, so once the readings behind this + // day moved, nothing can reproduce what it said. + healthScoreRecord: { + findMany: vi.fn().mockResolvedValue([ + { + id: "score-day-1", + userId: OWNER, + dayKey: "2026-07-18", + timezone: "Europe/Berlin", + composite: 76, + band: "yellow", + scoreVersion: 2, + composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"], + pillarScores: { BLOOD_PRESSURE: 86, SLEEP: 100, ADIPOSITY: 42 }, + inputFingerprint: "a".repeat(64), + configVersion: null, + configChangedAt: null, + computedAt: new Date("2026-07-18T20:00:00.000Z"), + createdAt: new Date("2026-07-18T20:00:00.000Z"), + }, + ]), + }, customMetric: { findMany: vi.fn().mockResolvedValue([ { @@ -586,6 +609,44 @@ describe("backup round trip — export, wire schema, restore", () => { expect(rows[0].hourlyCumulative).toEqual(DAY_CURVE); }); + it("writes the recorded score day back with the number it showed", async () => { + const { written } = await roundTrip(); + + const write = written.find( + (w) => w.model === "healthScoreRecord" && w.op === "createMany", + ); + expect( + write, + "the recorded score day reached the file and nothing wrote it back", + ).toBeDefined(); + + const rows = write!.data as unknown as Array>; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: "score-day-1", + userId: OWNER, + dayKey: "2026-07-18", + timezone: "Europe/Berlin", + composite: 76, + band: "yellow", + scoreVersion: 2, + inputFingerprint: "a".repeat(64), + }); + // The composition and the per-pillar scores are what keep the stored day + // readable across a later change to what counts. A row that came back + // with only its headline would look restored and answer nothing. + expect(rows[0].composition).toEqual([ + "BLOOD_PRESSURE", + "SLEEP", + "ADIPOSITY", + ]); + expect(rows[0].pillarScores).toEqual({ + BLOOD_PRESSURE: 86, + SLEEP: 100, + ADIPOSITY: 42, + }); + }); + it("writes the account's own cycle symptom back before resolving its links", async () => { const { written, res } = await roundTrip(); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 03d749045..f83ef5a35 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -40,6 +40,7 @@ import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; +import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -71,6 +72,7 @@ interface RestoreResponse { customMetrics: number; correlationPatterns: number; intradayProfiles: number; + healthScoreRecords: number; }; } @@ -764,6 +766,14 @@ const handler = apiHandler( payload, ); + // The score as it was shown on each local day. Both ends live in + // `src/lib/export/health-score-backup.ts`, same as the two above. + const healthScoreCleared = await restoreHealthScoreData( + tx, + ownerId, + payload, + ); + const biomarkerByName = new Map(); const restoredBiomarkerIds = new Set(); for (const biomarker of payload.biomarkers) { @@ -1128,6 +1138,7 @@ const handler = apiHandler( customMetrics: profileCleared.customMetrics, correlationPatterns: profileCleared.correlationPatterns, intradayProfiles: intradayCleared.intradayProfiles, + healthScoreRecords: healthScoreCleared.healthScoreRecords, }; }, { diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 9bbfa031b..6ef274132 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -20,7 +20,7 @@ import { computeBpInTargetFastPath, type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; -import { computeUserHealthScore } from "@/lib/analytics/score/reader"; +import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record"; import { isModuleEnabled } from "@/lib/modules/gate"; import { resolveRestMode } from "@/lib/illness/rest-mode"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; @@ -554,7 +554,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null; const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null; const scoredAt = new Date(); - const healthScore = await computeUserHealthScore({ + const healthScore = await computeAndRecordUserHealthScore({ userId: user.id, now: scoredAt, profile: { diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 7bb572d87..b1823aecb 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({ // The hourly shape of a cumulative day, read by // `buildIntradayProfileBackupSection`. intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, + healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -96,6 +97,7 @@ beforeEach(() => { vi.mocked(prisma.intradayCumulativeProfile.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValue([] as never); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 4a741cf58..4ac966527 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({ // The hourly shape of a cumulative day, read by // `buildIntradayProfileBackupSection`. intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, + healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts new file mode 100644 index 000000000..be4572072 --- /dev/null +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -0,0 +1,393 @@ +/** + * The shape of a stored score day, and the promise never to restate one. + * + * The database half of that promise is proved against real Postgres in + * `tests/integration/health-score-record.test.ts`; this file covers the parts + * a database cannot see: what the fingerprint covers, which pillars reach the + * row, and that the write path reaches for an insert that yields rather than + * an update that overwrites. + */ +import { describe, expect, it, vi } from "vitest"; + +import { HealthScoreBand } from "@/generated/prisma/client"; +import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage"; +import type { Derived } from "@/lib/insights/derived/types"; + +import { computeComposite } from "../composite"; + +import { + buildHealthScoreRecord, + healthScoreInputFingerprint, + recordHealthScore, + SCORE_BANDS, +} from "../record"; +import type { + HealthScoreReport, + PillarValue, + ScorePillarId, + ScorePillarResult, +} from "../types"; + +const NOW = new Date("2026-08-20T12:00:00.000Z"); + +const DOMAIN_BY_PILLAR: Record = { + BLOOD_PRESSURE: "cardiometabolic", + GLYCAEMIA: "cardiometabolic", + ACTIVITY: "activity", + SLEEP: "sleep", + ADIPOSITY: "adiposity", + WELLBEING: "wellbeing", + FITNESS: "fitness", + LIPIDS: "cardiometabolic", +}; + +function pillar( + id: ScorePillarId, + score: number, + options: { deltaIdentity?: string } = {}, +): ScorePillarResult { + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 28, + missing: [], + fullHistoryDays: 28, + }); + const value: PillarValue = { + score, + observed: { + label: `${score}`, + value: score, + unit: "score", + asOf: NOW.toISOString(), + sources: ["MANUAL"], + }, + reference: { + kind: "guideline-band", + low: 0, + high: 100, + label: "test reference", + source: "Test 2026", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: options.deltaIdentity ?? id, + }; + const result: Derived = buildOk({ + value, + coverage, + confidence, + provenance: { + inputs: [id], + source: "live", + windowDays: 28, + computedAt: NOW.toISOString(), + }, + }); + return { id, domain: DOMAIN_BY_PILLAR[id], result }; +} + +/** A report built through the real composite, not a hand-written envelope. */ +function report( + pillars: ScorePillarResult[], + available = pillars.map((p) => p.id), +): HealthScoreReport { + const composite = computeComposite({ + pillars, + availablePillars: available, + asOf: NOW, + }); + return { + composite, + pillars, + delta: null, + deltaReason: "no_previous_window", + scoreVersion: 2, + weightGoal: { status: "insufficient" } as HealthScoreReport["weightGoal"], + algorithmNotice: null, + }; +} + +const HEALTHY = [ + pillar("BLOOD_PRESSURE", 88), + pillar("SLEEP", 74), + pillar("ADIPOSITY", 66), +]; + +describe("stored bands", () => { + it("matches the band type the column actually accepts", () => { + // The column is a Postgres enum, so a band the composite can produce and + // the type does not carry is a constraint violation on a write path that + // swallows its own failures — the score would keep rendering and the day + // would silently go unrecorded. Both ends are literals: the generated + // enum on one side, the list the writer uses on the other. + expect(Object.values(HealthScoreBand).sort()).toEqual( + [...SCORE_BANDS].sort(), + ); + }); + + it("lists every band the composite can produce, and nothing else", () => { + const produced = new Set(); + for (const score of [95, 85, 65, 55, 40, 20, 0]) { + const built = report([ + pillar("BLOOD_PRESSURE", score), + pillar("SLEEP", score), + pillar("ADIPOSITY", score), + ]); + if (built.composite.status === "ok") + produced.add(built.composite.value.band); + } + expect([...produced].sort()).toEqual([...SCORE_BANDS].sort()); + }); +}); + +describe("input fingerprint", () => { + const base = { + scoreVersion: 2, + composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[], + pillars: HEALTHY, + }; + + it("is stable for the same standing", () => { + expect(healthScoreInputFingerprint(base)).toBe( + healthScoreInputFingerprint({ ...base, pillars: [...HEALTHY].reverse() }), + ); + }); + + it("moves when the algorithm version moves", () => { + expect(healthScoreInputFingerprint({ ...base, scoreVersion: 3 })).not.toBe( + healthScoreInputFingerprint(base), + ); + }); + + it("moves when the composition narrows", () => { + const narrowed = { + ...base, + composition: ["BLOOD_PRESSURE", "SLEEP"] as ScorePillarId[], + }; + expect(healthScoreInputFingerprint(narrowed)).not.toBe( + healthScoreInputFingerprint(base), + ); + }); + + it("moves when a different pillar set produces the same numbers", () => { + // Same count, same scores, same order, same scoring modes — only WHICH + // pillars counted differs. Without the pillar id in the hash this is the + // case that reads as an unchanged standing, and it is exactly the change + // a configuration edit makes. + const swapped = { + ...base, + composition: ["GLYCAEMIA", "SLEEP", "ADIPOSITY"] as ScorePillarId[], + pillars: [ + pillar("GLYCAEMIA", 88, { deltaIdentity: "shared" }), + pillar("SLEEP", 74, { deltaIdentity: "shared" }), + pillar("ADIPOSITY", 66, { deltaIdentity: "shared" }), + ], + }; + const original = { + ...base, + pillars: [ + pillar("BLOOD_PRESSURE", 88, { deltaIdentity: "shared" }), + pillar("SLEEP", 74, { deltaIdentity: "shared" }), + pillar("ADIPOSITY", 66, { deltaIdentity: "shared" }), + ], + }; + expect(healthScoreInputFingerprint(swapped)).not.toBe( + healthScoreInputFingerprint(original), + ); + }); + + it("moves when a counted pillar's score moves", () => { + const shifted = { + ...base, + pillars: [pillar("BLOOD_PRESSURE", 87), HEALTHY[1], HEALTHY[2]], + }; + expect(healthScoreInputFingerprint(shifted)).not.toBe( + healthScoreInputFingerprint(base), + ); + }); + + it("moves when a pillar's scoring mode changes under an unchanged score", () => { + // The number is identical; what produced it is not. Without the scoring + // identity in the hash the two standings would be indistinguishable, and + // the seam that a scoring-mode change deserves would never be drawn. + const remoded = { + ...base, + pillars: [ + pillar("BLOOD_PRESSURE", 88, { deltaIdentity: "bp:personal-target" }), + HEALTHY[1], + HEALTHY[2], + ], + }; + expect(healthScoreInputFingerprint(remoded)).not.toBe( + healthScoreInputFingerprint(base), + ); + }); + + it("ignores a pillar the composite did not count", () => { + const withSpectator = { + ...base, + pillars: [...HEALTHY, pillar("ACTIVITY", 12)], + }; + expect(healthScoreInputFingerprint(withSpectator)).toBe( + healthScoreInputFingerprint(base), + ); + }); +}); + +describe("building the row", () => { + it("records the composite, the band, and every counted pillar", () => { + const built = report(HEALTHY); + const draft = buildHealthScoreRecord(built, { + timezone: "Europe/Berlin", + now: NOW, + }); + expect(draft).not.toBeNull(); + expect(built.composite.status).toBe("ok"); + if (built.composite.status !== "ok") throw new Error("unreachable"); + expect(draft!.composite).toBe(built.composite.value.score); + expect(draft!.band).toBe(built.composite.value.band); + expect(draft!.composition).toEqual(built.composite.value.composition); + expect(draft!.pillarScores).toEqual({ + BLOOD_PRESSURE: 88, + SLEEP: 74, + ADIPOSITY: 66, + }); + expect(draft!.inputFingerprint).toMatch(/^[0-9a-f]{64}$/); + }); + + it("cuts the day on the account's clock, not on UTC", () => { + // 23:30 UTC on the 20th is already the 21st in Auckland. A row filed under + // the UTC day would put the score on a day the person never saw it. + const lateEvening = new Date("2026-08-20T23:30:00.000Z"); + const built = report(HEALTHY); + expect( + buildHealthScoreRecord(built, { + timezone: "Pacific/Auckland", + now: lateEvening, + })!.dayKey, + ).toBe("2026-08-21"); + expect( + buildHealthScoreRecord(built, { + timezone: "UTC", + now: lateEvening, + })!.dayKey, + ).toBe("2026-08-20"); + }); + + it("leaves out a pillar that was graded but not counted", () => { + // ACTIVITY is available and graded, but the composite is built from the + // three that were eligible. Storing the spectator would report a pillar + // as contributing when it did not. + const built = report( + [...HEALTHY, pillar("ACTIVITY", 12)], + ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"], + ); + const draft = buildHealthScoreRecord(built, { + timezone: "UTC", + now: NOW, + })!; + expect(Object.keys(draft.pillarScores)).not.toContain("ACTIVITY"); + expect(draft.composition).not.toContain("ACTIVITY"); + }); + + it("records nothing when the composite did not resolve", () => { + // Two domains: below the breadth floor, so there is no number. Absence has + // to reach the table as absence — a stored 0 would be a day the person is + // told they scored zero. + const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]); + expect(thin.composite.status).toBe("insufficient"); + expect( + buildHealthScoreRecord(thin, { timezone: "UTC", now: NOW }), + ).toBeNull(); + }); + + it("carries the configuration version and its change date through", () => { + const changedAt = new Date("2026-08-19T09:00:00.000Z"); + const draft = buildHealthScoreRecord(report(HEALTHY), { + timezone: "UTC", + now: NOW, + configVersion: 4, + configChangedAt: changedAt, + })!; + expect(draft.configVersion).toBe(4); + expect(draft.configChangedAt).toEqual(changedAt); + }); + + it("defaults the configuration columns to absent, never to a made-up version", () => { + const draft = buildHealthScoreRecord(report(HEALTHY), { + timezone: "UTC", + now: NOW, + })!; + expect(draft.configVersion).toBeNull(); + expect(draft.configChangedAt).toBeNull(); + }); +}); + +describe("writing the row", () => { + /** + * A delegate that answers `createMany` and nothing else. Reaching for + * `upsert` or `update` throws here rather than quietly restating a day, so + * the never-restate promise is a compile-and-run property of this file and + * not a comment. + */ + function delegate(count: number) { + const createMany = vi.fn(async (args: { skipDuplicates?: boolean }) => { + void args; + return { count }; + }); + return { + db: { healthScoreRecord: { createMany } } as never, + createMany, + }; + } + + it("inserts with a conflict that yields, and never updates", async () => { + const { db, createMany } = delegate(1); + const outcome = await recordHealthScore(db, "user-1", report(HEALTHY), { + timezone: "UTC", + now: NOW, + }); + expect(outcome).toBe("written"); + expect(createMany).toHaveBeenCalledTimes(1); + expect(createMany.mock.calls[0][0]).toMatchObject({ skipDuplicates: true }); + }); + + it("reports the day as already recorded when the insert yielded", async () => { + const { db } = delegate(0); + expect( + await recordHealthScore(db, "user-1", report(HEALTHY), { + timezone: "UTC", + now: NOW, + }), + ).toBe("already_recorded"); + }); + + it("never touches the database when there is no score to record", async () => { + const { db, createMany } = delegate(1); + const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]); + expect( + await recordHealthScore(db, "user-1", thin, { + timezone: "UTC", + now: NOW, + }), + ).toBe("no_score"); + expect(createMany).not.toHaveBeenCalled(); + }); + + it("reports a failed write as failed, and does not take the read down", async () => { + const db = { + healthScoreRecord: { + createMany: vi.fn(async () => { + throw new Error("connection reset"); + }), + }, + } as never; + await expect( + recordHealthScore(db, "user-1", report(HEALTHY), { + timezone: "UTC", + now: NOW, + }), + ).resolves.toBe("failed"); + }); +}); diff --git a/src/lib/analytics/score/derived.ts b/src/lib/analytics/score/derived.ts index 1c61420c1..f06b82ae0 100644 --- a/src/lib/analytics/score/derived.ts +++ b/src/lib/analytics/score/derived.ts @@ -6,7 +6,7 @@ import { resolveModuleMap } from "@/lib/modules/gate"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { computeUserHealthScore } from "./reader"; +import { computeAndRecordUserHealthScore } from "./record"; import type { CompositeValue } from "./types"; const DAY_MS = 86_400_000; @@ -51,7 +51,7 @@ export async function computeHealthScoreDerived( bpAt(new Date(now.getTime() - 7 * DAY_MS)), bpAt(new Date(now.getTime() - 14 * DAY_MS)), ]); - const report = await computeUserHealthScore({ + const report = await computeAndRecordUserHealthScore({ userId, now, profile: { diff --git a/src/lib/analytics/score/record.ts b/src/lib/analytics/score/record.ts new file mode 100644 index 000000000..930829612 --- /dev/null +++ b/src/lib/analytics/score/record.ts @@ -0,0 +1,308 @@ +/** + * Writing the health score down. + * + * The score is computed live, every time, from the rows standing behind it at + * that moment. That is the right thing for today and it is why nothing older + * than today survives: correct a reading from last month, finish a sync that + * had stalled, or simply add the fourteenth night of sleep that made a pillar + * eligible, and the score the person saw back then can no longer be + * reproduced. There is nothing dishonest about that, but it does mean the + * "history" a surface can offer today is two numbers computed seconds apart. + * + * So each local day's score is written down once, the first time it is + * computed, and then left alone. Three properties follow, and all three are + * deliberate: + * + * * **Append-only.** A second computation of the same local day finds the + * row and does nothing. Not an upsert with a cleverer conflict target, + * not a "refresh if newer" — the number the person saw stays the number + * they saw. Restating it would make the stored series agree with whatever + * the data happens to look like now, which is precisely the property that + * makes it worth nothing. + * * **Never authoritative.** No surface reads this instead of computing. + * The record answers questions about past days; today's number always + * comes from today's rows. + * * **Never fatal.** The write hangs off a read path. A score that renders + * must not fail because a bookkeeping insert did, so the failure is + * annotated onto the wide event and swallowed. The integration round trip + * is what proves the write actually happens; a unit test that only + * watches this function return would be watching it succeed at nothing. + * + * Server-only — reaches for the Prisma client. + */ +import { createHash } from "node:crypto"; + +import { prisma as globalPrisma } from "@/lib/db"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { userDayKey } from "@/lib/tz/format"; + +import { computeUserHealthScore, type UserHealthScoreInput } from "./reader"; +import type { + HealthScoreReport, + ScoreBand, + ScorePillarId, + ScorePillarResult, +} from "./types"; + +/** + * The bands a stored row may carry, and the same closed set the migration's + * CHECK constraint spells out. + * + * Pinned to {@link ScoreBand} in BOTH directions at compile time: `satisfies` + * rejects a member that is not a band, and `_BandsAreExhaustive` fails to + * compile if a band is ever added to the union without being added here. A + * one-directional check would let a fourth band reach a column that refuses + * it, which is a runtime constraint violation on a write path that is + * supposed to be unable to fail loudly. + */ +export const SCORE_BANDS = [ + "green", + "yellow", + "red", +] as const satisfies readonly ScoreBand[]; + +type _BandsAreExhaustive = + Exclude extends never ? true : never; +const _bandsAreExhaustive: _BandsAreExhaustive = true; +void _bandsAreExhaustive; + +/** Fingerprint format version. Bump when the covered set below changes. */ +const FINGERPRINT_VERSION = 1; + +/** The row a computed report turns into, before it reaches the database. */ +export interface HealthScoreRecordDraft { + dayKey: string; + timezone: string; + composite: number; + band: ScoreBand; + scoreVersion: number; + composition: ScorePillarId[]; + pillarScores: Record; + inputFingerprint: string; + configVersion: number | null; + configChangedAt: Date | null; + computedAt: Date; +} + +/** What the caller knows that the report itself does not carry. */ +export interface HealthScoreRecordContext { + /** The account's IANA zone — the clock the local day is cut on. */ + timezone: string; + /** The instant the score was computed for. */ + now: Date; + /** + * The account's health-score configuration version in force for this + * computation, and when it last changed. Both stay `null` until the + * per-user configuration exists; the parameters are here so threading them + * through later is a caller change and not a migration. + */ + configVersion?: number | null; + configChangedAt?: Date | null; +} + +/** + * Every pillar that actually counted, in the composition's own order, with + * the score and the scoring-mode identity that produced it. + * + * Reads the composition rather than the pillar list, because the pillar list + * carries pillars that were graded and then not counted (insufficient + * coverage, module off). Storing those would make the fingerprint move for + * reasons the composite never saw. + */ +function countedPillars( + composition: readonly ScorePillarId[], + pillars: readonly ScorePillarResult[], +): Array<{ id: ScorePillarId; score: number; deltaIdentity: string }> { + const byId = new Map(pillars.map((pillar) => [pillar.id, pillar])); + return composition.flatMap((id) => { + const pillar = byId.get(id); + if (!pillar || pillar.result.status !== "ok") return []; + return [ + { + id, + score: pillar.result.value.score, + deltaIdentity: pillar.result.value.deltaIdentity, + }, + ]; + }); +} + +/** + * SHA-256 over exactly what the stored composite and band depend on. + * + * The covered set is the whole point, so it is spelled out rather than + * "hash the report": the algorithm version, and then one ordered triple per + * counted pillar — its id, its score, and the scoring mode that produced that + * score. Drop any one of those and two genuinely different standings collapse + * onto one fingerprint, which is a key missing a dimension its value depends + * on, a defect class this codebase has shipped before. + * + * The composition is not hashed separately. It IS the ordered list of ids in + * those triples, and an earlier draft carried it twice: a key that no input + * could ever make disagree with its neighbour, and therefore a key no test + * could make fail. A hash term nothing can move is not defence in depth, it + * is decoration on a guard. + * + * What is deliberately NOT covered: the account's configuration version. A + * configuration decides the composition, and the composition is already here. + * A configuration change that leaves the composition alone leaves the score + * alone too, and a fingerprint that moved anyway would report a break that + * did not happen. + */ +export function healthScoreInputFingerprint(input: { + scoreVersion: number; + composition: readonly ScorePillarId[]; + pillars: readonly ScorePillarResult[]; +}): string { + const counted = countedPillars(input.composition, input.pillars); + const canonical = JSON.stringify({ + v: FINGERPRINT_VERSION, + scoreVersion: input.scoreVersion, + pillars: counted.map((pillar) => [ + pillar.id, + pillar.score, + pillar.deltaIdentity, + ]), + }); + return createHash("sha256").update(canonical).digest("hex"); +} + +/** + * Turn a computed report into the row it would be stored as, or `null` when + * there is nothing to record. + * + * `null` means the composite did not resolve — too few domains, no + * physiological pillar, a read that failed. That is honest absence and it is + * NOT a row: a stored zero, or a stored "insufficient" placeholder, would put + * a value on a day that never had one, and every reader downstream would have + * to know to disbelieve it. + * + * Pure, so the fingerprint and the shape can be tested without a database. + */ +export function buildHealthScoreRecord( + report: HealthScoreReport, + context: HealthScoreRecordContext, +): HealthScoreRecordDraft | null { + if (report.composite.status !== "ok") return null; + const composite = report.composite.value; + const counted = countedPillars(composite.composition, report.pillars); + if (counted.length === 0) return null; + + return { + dayKey: userDayKey(context.now, context.timezone), + timezone: context.timezone, + composite: composite.score, + band: composite.band, + scoreVersion: composite.scoreVersion, + composition: [...composite.composition], + pillarScores: Object.fromEntries( + counted.map((pillar) => [pillar.id, pillar.score]), + ), + inputFingerprint: healthScoreInputFingerprint({ + scoreVersion: composite.scoreVersion, + composition: composite.composition, + pillars: report.pillars, + }), + configVersion: context.configVersion ?? null, + configChangedAt: context.configChangedAt ?? null, + computedAt: context.now, + }; +} + +/** What the write did, so a caller or a test can tell the three apart. */ +export type HealthScoreRecordOutcome = + /** A new day was recorded. */ + | "written" + /** The day already had a record, which was left exactly as it was. */ + | "already_recorded" + /** Nothing to record — the composite did not resolve. */ + | "no_score" + /** The insert failed. Annotated, not thrown. */ + | "failed"; + +type RecordDelegate = Pick; + +/** + * Record one local day's score, once. + * + * `createMany` with `skipDuplicates` compiles to `ON CONFLICT DO NOTHING` + * against the `(user_id, day_key)` unique index, so two concurrent requests + * on the same day race into the same outcome the second request would have + * reached anyway: the first row stands. An `upsert` would have been the + * obvious call here and would have been wrong — its update branch is exactly + * the restatement this table must not do. + */ +export async function recordHealthScore( + db: RecordDelegate, + userId: string, + report: HealthScoreReport, + context: HealthScoreRecordContext, +): Promise { + const draft = buildHealthScoreRecord(report, context); + if (!draft) return "no_score"; + + try { + const written = await db.healthScoreRecord.createMany({ + data: [ + { + userId, + dayKey: draft.dayKey, + timezone: draft.timezone, + composite: draft.composite, + band: draft.band, + scoreVersion: draft.scoreVersion, + composition: draft.composition, + pillarScores: draft.pillarScores as Prisma.InputJsonValue, + inputFingerprint: draft.inputFingerprint, + configVersion: draft.configVersion, + configChangedAt: draft.configChangedAt, + computedAt: draft.computedAt, + }, + ], + skipDuplicates: true, + }); + if (written.count === 0) return "already_recorded"; + annotate({ + action: { name: "score.record.write" }, + meta: { + dayKey: draft.dayKey, + scoreVersion: draft.scoreVersion, + pillars: draft.composition.length, + }, + }); + return "written"; + } catch (err) { + // A failed bookkeeping write must not take a rendered score down with it, + // but it must not pass for silence either: the warning rides the wide + // event so an operator sees the day that went unrecorded. + getEvent()?.addWarning( + `health score record write failed for ${draft.dayKey}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return "failed"; + } +} + +/** + * Compute the account's health score and record the day it was computed for. + * + * One entry point rather than a write bolted onto each of the surfaces that + * compute a score. Three callers each remembering to record is three places + * that can independently stop remembering, and the one that forgets leaves a + * gap in the series that nothing reports. + */ +export async function computeAndRecordUserHealthScore( + input: UserHealthScoreInput, + record?: Pick, +): Promise { + const report = await computeUserHealthScore(input); + await recordHealthScore(input.prisma ?? globalPrisma, input.userId, report, { + timezone: input.profile.timezone, + now: input.now, + configVersion: record?.configVersion ?? null, + configChangedAt: record?.configChangedAt ?? null, + }); + return report; +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index f2f45d9b9..de3438d24 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -77,7 +77,7 @@ import { buildMedsTodayBlock, type MedsTodayBlock, } from "@/lib/dashboard/meds-today"; -import { computeUserHealthScore } from "@/lib/analytics/score/reader"; +import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record"; import { buildDashboardBands as buildTargetBands, type DashboardTargetBands, @@ -742,7 +742,7 @@ async function buildExtras( // prior-week delta values). Closes the dashboard-vs-insights divergence. const scoreSourcePriority = user.sourcePriorityJson ?? null; const scoreResult = await time("healthScore", () => - computeUserHealthScore({ + computeAndRecordUserHealthScore({ prisma, userId: user.id, now, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index a0abfff9e..f5dde6e38 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -54,6 +54,12 @@ export const WIPE_MODELS = [ // record and nothing else holds it, so a wipe that leaves it behind leaves // behind an account's activity pattern. "IntradayCumulativeProfile", + // The health score as it was shown, one row per local day. Not a cache of + // the live computation — nothing can rebuild a past day once the readings + // behind it have moved — so leaving it behind leaves behind a record of how + // the account was doing, day by day, after a confirmation that said + // otherwise. + "HealthScoreRecord", "MeasurementReminder", // ── Medication ────────────────────────────────────────────────────────── diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index eb0e5794d..810174ed8 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 makePrisma() { }, ]), }, + healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 6c78f9096..07f0505c6 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -103,6 +103,12 @@ export const BACKED_UP_MODELS = [ "CustomMetric", "CustomMetricEntry", "CorrelationPattern", + // Reads like a derived tier and is not one. The live computation always + // answers for today from today's rows, so once the readings behind a past + // day have been corrected, re-synced or simply grown, nothing can reproduce + // what that day said. The row is the only copy of the number the account + // actually saw, which is exactly the test for BACKED_UP rather than DERIVED. + "HealthScoreRecord", // ── Activity and nutrition ──────────────────────────────────────────────── "Workout", @@ -157,6 +163,7 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ "src/lib/export/records-backup.ts", "src/lib/export/profile-backup.ts", "src/lib/export/intraday-profile-backup.ts", + "src/lib/export/health-score-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -164,6 +171,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/app/api/admin/backups/[id]/restore/route.ts", "src/lib/export/profile-backup.ts", "src/lib/export/intraday-profile-backup.ts", + "src/lib/export/health-score-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -206,6 +214,7 @@ export const TWO_ENDED_MODELS: readonly string[] = [ "CustomMetric", "CustomMetricEntry", "CorrelationPattern", + "HealthScoreRecord", "Workout", "NutrientIntakeDay", "InboundDocument", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 4a8d970ea..e0452a9f1 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -31,6 +31,12 @@ import { type ProfileBackupCounts, type ProfileBackupSection, } from "@/lib/export/profile-backup"; +import { + buildHealthScoreBackupSection, + countHealthScoreBackupSection, + type HealthScoreBackupCounts, + type HealthScoreBackupSection, +} from "@/lib/export/health-score-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -42,7 +48,8 @@ export interface FullBackupCounts extends RecordsBackupCounts, ProfileBackupCounts, - IntradayProfileBackupCounts { + IntradayProfileBackupCounts, + HealthScoreBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -117,6 +124,7 @@ export async function buildFullBackupPayload( records, profile, intradayProfiles, + healthScoreRecords, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -225,6 +233,13 @@ export async function buildFullBackupPayload( buildIntradayProfileBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The score as it was SHOWN on each local day. Reads like a derived tier + // and is not one: today's number always comes from today's rows, so once + // the readings behind a past day have moved, nothing can recompute what + // that day said. The row is the only copy. + buildHealthScoreBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -250,6 +265,7 @@ export async function buildFullBackupPayload( const recordsSection: RecordsBackupSection = records; const profileSection: ProfileBackupSection = profile; const intradaySection: IntradayProfileBackupSection = intradayProfiles; + const healthScoreSection: HealthScoreBackupSection = healthScoreRecords; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -396,6 +412,7 @@ export async function buildFullBackupPayload( ...recordsSection, ...profileSection, ...intradaySection, + ...healthScoreSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -418,6 +435,7 @@ export async function buildFullBackupPayload( ...countRecordsBackupSection(records), ...countProfileBackupSection(profile), ...countIntradayProfileBackupSection(intradayProfiles), + ...countHealthScoreBackupSection(healthScoreRecords), }, }; } diff --git a/src/lib/export/health-score-backup.ts b/src/lib/export/health-score-backup.ts new file mode 100644 index 000000000..a0a70a0b8 --- /dev/null +++ b/src/lib/export/health-score-backup.ts @@ -0,0 +1,206 @@ +/** + * The stored health score days, with both backup ends in one file. + * + * `HealthScoreRecord` looks derived and is not. The live computation answers + * for today from today's rows, so a day whose readings have since been + * corrected, re-synced or simply extended cannot be recomputed as it stood; + * the row is the only copy of the number the account actually saw. Classifying + * it `DERIVED` and leaving it out would produce a restore that comes back with + * a blank history and reports success, which is the shape of defect the + * nutrient day totals, the custom mood tags and the custom cycle symptoms each + * shipped in turn. + * + * The builder and the restore live together deliberately, the way + * `intraday-profile-backup.ts` and `src/lib/cycle/backup.ts` do: a reader + * asking "is this carried at both ends?" answers it in one file, and a reader + * who greps only the restore ROUTE gets a false negative, because the route + * delegates. + * + * Nothing here is encrypted, so there is no portable/disaster-recovery split + * on the values. The only difference between the two purposes is identity: a + * canonical disaster-recovery payload carries the row id and its timestamps so + * the row comes back as itself, a portable export carries the day alone. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import type { ScoreBand } from "@/lib/analytics/score/types"; + +export interface HealthScoreBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One local day's score, exactly as it was shown. */ +export interface HealthScoreRecordBackupEntry { + /** Present in canonical DR payloads so the row keeps a stable identity. */ + id?: string; + /** Local calendar day (`YYYY-MM-DD`) in `timezone`. */ + dayKey: string; + /** + * The zone the day was cut on. Carried for the same reason the intraday + * curves carry theirs: an account that moved zones has days of different + * lengths behind it, and a restore that guessed would make them look + * comparable. + */ + timezone: string; + composite: number; + band: ScoreBand; + scoreVersion: number; + composition: string[]; + /** Each counted pillar's own score, keyed by pillar id. */ + pillarScores: Record; + inputFingerprint: string; + configVersion: number | null; + configChangedAt: string | null; + computedAt: string; + createdAt?: string; +} + +export interface HealthScoreBackupSection { + healthScoreRecords: HealthScoreRecordBackupEntry[]; +} + +export interface HealthScoreBackupCounts { + healthScoreRecords: number; +} + +/** + * Read the stored score days as JSON-safe entries. + * + * `pillarScores` arrives as an opaque `JsonValue`; it is narrowed here rather + * than cast, so a row holding something other than a flat id-to-number map + * reaches the payload as an empty map instead of as a shape the restore's + * schema would reject at the worst possible moment. + */ +function readPillarScores(value: Prisma.JsonValue): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === "number") out[key] = entry; + } + return out; +} + +/** + * Build the stored-score slice of a user's full backup. + * + * Takes the delegate it uses rather than a whole `PrismaClient`, matching the + * other section builders, so the route's global client and the worker's local + * one share one read. + */ +export async function buildHealthScoreBackupSection( + prisma: Pick, + userId: string, + options: HealthScoreBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + // The table carries no tombstone — a day is written once and never deleted + // short of a wipe — so both purposes read the same set. + const rows = await prisma.healthScoreRecord.findMany({ + where: { userId }, + orderBy: { dayKey: "desc" }, + }); + + return { + healthScoreRecords: rows.map((row) => ({ + ...(disasterRecovery + ? { id: row.id, createdAt: row.createdAt.toISOString() } + : {}), + dayKey: row.dayKey, + timezone: row.timezone, + composite: row.composite, + band: row.band, + scoreVersion: row.scoreVersion, + composition: row.composition, + pillarScores: readPillarScores(row.pillarScores), + inputFingerprint: row.inputFingerprint, + configVersion: row.configVersion, + configChangedAt: row.configChangedAt?.toISOString() ?? null, + computedAt: row.computedAt.toISOString(), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countHealthScoreBackupSection( + section: HealthScoreBackupSection, +): HealthScoreBackupCounts { + return { healthScoreRecords: section.healthScoreRecords.length }; +} + +/** Counts the score-record restore wiped, for the audit trail. */ +export interface HealthScoreRestoreCleared { + healthScoreRecords: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: `BackupPayload` defaults it, so every caller + * already satisfies this, and a caller that stops satisfying it fails to + * compile instead of passing `undefined` into a loop that iterates zero times + * and reports success. + */ +export interface HealthScoreRestoreInput { + healthScoreRecords: HealthScoreRecordBackupEntry[]; +} + +/** + * Re-create the account's stored score days. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. Note the asymmetry with the live write path next door: THAT one + * yields on conflict, because a second computation of a day it already holds + * must change nothing. This one does not, because the file is the authority + * here and a day it carries twice is a corrupt file, not a race. Two entries + * claiming the same local day therefore throw with a sentence rather than + * letting `skipDuplicates` pick a winner and report success. + */ +export async function restoreHealthScoreData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: HealthScoreRestoreInput, +): Promise { + const cleared = await tx.healthScoreRecord.deleteMany({ + where: { userId: ownerId }, + }); + + const seen = new Set(); + for (const record of payload.healthScoreRecords) { + if (seen.has(record.dayKey)) { + throw new Error( + `Duplicate health score record for ${record.dayKey}. One account has ` + + "at most one score per local day, and picking a winner here would " + + "silently discard the other reading of that day.", + ); + } + seen.add(record.dayKey); + } + + if (payload.healthScoreRecords.length > 0) { + await tx.healthScoreRecord.createMany({ + data: payload.healthScoreRecords.map((record) => ({ + ...(record.id ? { id: record.id } : {}), + userId: ownerId, + dayKey: record.dayKey, + timezone: record.timezone, + composite: record.composite, + band: record.band, + scoreVersion: record.scoreVersion, + composition: record.composition, + pillarScores: record.pillarScores as Prisma.InputJsonValue, + inputFingerprint: record.inputFingerprint, + configVersion: record.configVersion, + configChangedAt: record.configChangedAt + ? new Date(record.configChangedAt) + : null, + computedAt: new Date(record.computedAt), + ...(record.createdAt ? { createdAt: new Date(record.createdAt) } : {}), + })), + }); + } + + return { healthScoreRecords: cleared.count }; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b7917266e..07cf9d86c 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -577,6 +577,30 @@ const intradayProfileBackupSchema = z }) .passthrough(); +const healthScoreRecordBackupSchema = z + .object({ + id: z.string().min(1).optional(), + dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + // The zone the day was cut on. An account that moved zones has days of + // different lengths behind it, and a restore that guessed would make them + // look comparable. + timezone: z.string().min(1), + composite: z.number().int().min(0).max(100), + // The same closed set the column's CHECK constraint carries. A band the + // schema let through and the database refused would fail the whole + // restore transaction on one bad row. + band: z.enum(["green", "yellow", "red"]), + scoreVersion: z.number().int(), + composition: z.array(z.string().min(1)).min(1), + pillarScores: z.record(z.string(), z.number()), + inputFingerprint: z.string().regex(/^[0-9a-f]{64}$/), + configVersion: z.number().int().nullable().default(null), + configChangedAt: isoDateTime.nullable().default(null), + computedAt: isoDateTime, + createdAt: isoDateTime.optional(), + }) + .passthrough(); + const appSettingsBackupSchema = z .object({ id: z.string().min(1), @@ -849,6 +873,10 @@ export const backupPayloadSchema = z // The hourly shape of a cumulative day. Defaulted for the same reason as // the pair above: a file written before the table existed carries no key. intradayProfiles: z.array(intradayProfileBackupSchema).default([]), + // The score as it was shown, day by day. Defaulted for the same reason as + // the sections above: a file written before the table existed carries no + // key, and an account whose score never resolved writes []. + healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) .passthrough() @@ -913,6 +941,8 @@ export interface BackupSummary { correlationPatterns: number; /** Stored day curves for the cumulative metrics, across every metric. */ intradayProfiles: number; + /** Local days whose health score was written down as it was shown. */ + healthScoreRecords: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -947,6 +977,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { ), correlationPatterns: payload.correlationPatterns.length, intradayProfiles: payload.intradayProfiles.length, + healthScoreRecords: payload.healthScoreRecords.length, }; } diff --git a/tests/integration/health-score-record.test.ts b/tests/integration/health-score-record.test.ts new file mode 100644 index 000000000..b17ecbe6f --- /dev/null +++ b/tests/integration/health-score-record.test.ts @@ -0,0 +1,407 @@ +/** + * The persisted health score day, end to end against real Postgres. + * + * A mocked Prisma proves nothing here. Every claim this file makes is about + * what is actually in the table after a real request: that computing a score + * writes the day down, that computing it again under different readings + * leaves the earlier day exactly as it stood, and that the row survives a + * backup and a restore rather than coming back blank while the restore + * reports success. + * + * The three lifecycle paths a new user-scoped table has to join, and where + * each is proved: + * + * - delete-all-data — `tests/integration/settings-data-wipe.test.ts` seeds + * every table from `information_schema` and asserts every model in the + * wipe plan is empty afterwards, so this table is covered there the + * moment it enters the plan. `src/__tests__/data-wipe-completeness.test.ts` + * is what forces it into the plan in the first place. + * - the backup writer — proved below over `buildFullBackupPayload`, which + * is the builder the weekly worker and both export routes share. + * - the restore path — proved below over the real admin restore ROUTE, not + * over the helper it delegates to. That distinction is the whole point: + * the structural guard in `backup-plan-classification.test.ts` reads the + * declared restore FILES, so a helper that exists and is never called + * passes it. Only a round trip through the route catches that. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { caches } from "@/lib/cache/server-cache"; +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), + invalidateUserHealthScore: vi.fn(), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +const DAY = 24 * 60 * 60 * 1000; + +interface AnalyticsEnvelope { + data: { + healthScore: { + composite: { + status: "ok" | "insufficient"; + value?: { score: number; band: string; composition: string[] }; + }; + } | null; + } | null; +} + +async function seedSession(username: string, role: "USER" | "ADMIN" = "USER") { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role, + heightCm: 178, + dateOfBirth: new Date("1985-07-09"), + // Pinned so the local day the row is filed under is a fact of the + // fixture rather than a property of the machine running the suite. + timezone: "UTC", + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +/** BP needs twelve paired readings inside the trailing 90-day window. */ +async function seedBp(userId: string, now: number, days: number, sys: number) { + const prisma = getPrismaClient(); + for (let i = 0; i < days; i++) { + const at = new Date(now - i * DAY); + await prisma.measurement.create({ + data: { + userId, + type: "BLOOD_PRESSURE_SYS", + value: sys, + unit: "mmHg", + measuredAt: at, + }, + }); + await prisma.measurement.create({ + data: { + userId, + type: "BLOOD_PRESSURE_DIA", + value: 78, + unit: "mmHg", + measuredAt: at, + }, + }); + } +} + +/** SLEEP needs fourteen distinct wake-day nights. */ +async function seedSleep(userId: string, now: number, nights: number) { + const prisma = getPrismaClient(); + for (let i = 0; i < nights; i++) { + const wake = new Date(now - i * DAY); + wake.setUTCHours(6, 0, 0, 0); + await prisma.measurement.create({ + data: { + userId, + type: "SLEEP_DURATION", + value: 450, + unit: "min", + measuredAt: wake, + sleepStage: "ASLEEP", + source: "APPLE_HEALTH", + }, + }); + } +} + +/** ADIPOSITY needs one waist reading against the seeded height. */ +async function seedWaist(userId: string, now: number) { + await getPrismaClient().measurement.create({ + data: { + userId, + type: "WAIST_CIRCUMFERENCE", + value: 82, + unit: "cm", + measuredAt: new Date(now), + }, + }); +} + +async function readAnalytics(): Promise { + const { GET } = await import("@/app/api/analytics/route"); + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost/api/analytics"), + ); + expect(res.status).toBe(200); + return (await res.json()) as AnalyticsEnvelope; +} + +describe("the score a surface shows is the score that gets written down", () => { + it("persists the composite, band and composition the analytics route returned", async () => { + const user = await seedSession("hsr-roundtrip"); + const now = Date.now(); + await seedBp(user.id, now, 20, 122); + await seedSleep(user.id, now, 14); + await seedWaist(user.id, now); + + const shown = (await readAnalytics()).data!.healthScore!; + expect(shown.composite.status).toBe("ok"); + + const rows = await getPrismaClient().healthScoreRecord.findMany({ + where: { userId: user.id }, + }); + expect(rows).toHaveLength(1); + const row = rows[0]; + // The shown value and the stored value are not compared through a fixture + // in the middle — the number asserted here came back over the wire from + // the same request that wrote the row. + expect(row.composite).toBe(shown.composite.value!.score); + expect(row.band).toBe(shown.composite.value!.band); + expect(row.composition).toEqual(shown.composite.value!.composition); + expect(row.pillarScores).toBeTruthy(); + // JSONB does not preserve insertion order, so the SET of keys is the + // claim, not their order. The order that matters lives in `composition`, + // which is a text array and asserted above. + expect( + Object.keys(row.pillarScores as Record).sort(), + ).toEqual([...shown.composite.value!.composition].sort()); + expect(row.inputFingerprint).toMatch(/^[0-9a-f]{64}$/); + expect(row.timezone).toBe("UTC"); + expect(row.dayKey).toBe(new Date(now).toISOString().slice(0, 10)); + // Nothing supplies these yet, and an invented default would be worse than + // an honest null the day the delta guard starts reading them. + expect(row.configVersion).toBeNull(); + expect(row.configChangedAt).toBeNull(); + }); + + it("leaves the recorded day untouched when the same day is computed again under a changed standing", async () => { + const user = await seedSession("hsr-never-restate"); + const now = Date.now(); + await seedBp(user.id, now, 20, 122); + await seedSleep(user.id, now, 14); + await seedWaist(user.id, now); + + const first = (await readAnalytics()).data!.healthScore!; + expect(first.composite.status).toBe("ok"); + const before = await getPrismaClient().healthScoreRecord.findFirstOrThrow({ + where: { userId: user.id }, + }); + + // Change what the score is made of, two ways at once: a fourth pillar + // joins the composition, and blood pressure grades worse. Either alone + // moves the live number. + await getPrismaClient().measurement.deleteMany({ + where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" }, + }); + await getPrismaClient().measurement.deleteMany({ + where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" }, + }); + await seedBp(user.id, now, 20, 168); + for (let i = 0; i < 30; i++) { + await getPrismaClient().measurement.create({ + data: { + userId: user.id, + type: "ACTIVITY_STEPS", + value: 11_000, + unit: "steps", + measuredAt: new Date(now - i * DAY), + source: "APPLE_HEALTH", + }, + }); + } + + // `/api/analytics` reads through a 60-second cache, so without this the + // second request would replay the first request's body and nothing would + // recompute. The assertion below is what caught it. + caches.analytics.deleteByPrefix(`${user.id}|`); + caches.insightsDerived.deleteByPrefix(`${user.id}|`); + + const second = (await readAnalytics()).data!.healthScore!; + expect(second.composite.status).toBe("ok"); + // The live number really did move — without this the test below would + // pass against a recomputation that changed nothing. + expect(second.composite.value!.score).not.toBe( + first.composite.value!.score, + ); + + const after = await getPrismaClient().healthScoreRecord.findMany({ + where: { userId: user.id }, + }); + expect(after).toHaveLength(1); + expect(after[0]).toEqual(before); + }); +}); + +describe("the recorded day survives a backup and a restore", () => { + it("carries the row out through the shared backup builder", async () => { + const user = await seedSession("hsr-backup"); + const now = Date.now(); + await seedBp(user.id, now, 20, 122); + await seedSleep(user.id, now, 14); + await seedWaist(user.id, now); + await readAnalytics(); + + const stored = await getPrismaClient().healthScoreRecord.findFirstOrThrow({ + where: { userId: user.id }, + }); + const { payload, counts } = await buildFullBackupPayload( + getPrismaClient(), + user.id, + { purpose: "disaster-recovery" }, + ); + expect(counts.healthScoreRecords).toBe(1); + const carried = (payload as { healthScoreRecords: unknown[] }) + .healthScoreRecords; + expect(carried).toHaveLength(1); + expect(carried[0]).toMatchObject({ + id: stored.id, + dayKey: stored.dayKey, + timezone: stored.timezone, + composite: stored.composite, + band: stored.band, + scoreVersion: stored.scoreVersion, + composition: stored.composition, + inputFingerprint: stored.inputFingerprint, + }); + // And the file it produced is one the restore side will accept, rather + // than one that parses everywhere except at the moment it is needed. + expect( + parseBackupPayload(JSON.stringify(payload)).healthScoreRecords, + ).toHaveLength(1); + }); + + it("brings the row back through the admin restore route", async () => { + const prisma = getPrismaClient(); + const admin = await seedSession("hsr-restore", "ADMIN"); + const now = Date.now(); + await seedBp(admin.id, now, 20, 122); + await seedSleep(admin.id, now, 14); + await seedWaist(admin.id, now); + await readAnalytics(); + + const before = await prisma.healthScoreRecord.findFirstOrThrow({ + where: { userId: admin.id }, + }); + const { payload } = await buildFullBackupPayload(prisma, admin.id, { + purpose: "disaster-recovery", + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "HEALTH_SCORE_RECORD_RESTORE_TEST", + data: encrypt(JSON.stringify(payload)), + }, + }); + + // Wipe the table so a restore that carries nothing cannot pass by leaving + // the row that was already there. + await prisma.healthScoreRecord.deleteMany({ where: { userId: admin.id } }); + expect( + await prisma.healthScoreRecord.count({ where: { userId: admin.id } }), + ).toBe(0); + + const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const res = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = (await res.json()) as { + data: { summary: { healthScoreRecords: number } } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(res.status).toBe(200); + expect(body.data!.summary.healthScoreRecords).toBe(1); + + const restored = await prisma.healthScoreRecord.findMany({ + where: { userId: admin.id }, + }); + expect(restored).toHaveLength(1); + expect(restored[0].id).toBe(before.id); + expect(restored[0].dayKey).toBe(before.dayKey); + expect(restored[0].composite).toBe(before.composite); + expect(restored[0].band).toBe(before.band); + expect(restored[0].composition).toEqual(before.composition); + expect(restored[0].pillarScores).toEqual(before.pillarScores); + expect(restored[0].inputFingerprint).toBe(before.inputFingerprint); + expect(restored[0].computedAt.toISOString()).toBe( + before.computedAt.toISOString(), + ); + }); +}); + +describe("delete all data", () => { + it("removes the recorded days along with the rest of the record", async () => { + const user = await seedSession("hsr-wipe"); + const now = Date.now(); + await seedBp(user.id, now, 20, 122); + await seedSleep(user.id, now, 14); + await seedWaist(user.id, now); + await readAnalytics(); + expect( + await getPrismaClient().healthScoreRecord.count({ + where: { userId: user.id }, + }), + ).toBe(1); + + const { DELETE } = await import("@/app/api/settings/data/route"); + const res = await ( + DELETE as unknown as (req: Request) => Promise + )( + new Request("http://localhost/api/settings/data", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE" }), + }), + ); + expect(res.status).toBe(200); + expect( + await getPrismaClient().healthScoreRecord.count({ + where: { userId: user.id }, + }), + ).toBe(0); + }); +});