From cb75986eb5e6cfd8cdb7b468ef82d7796e660cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:02:01 +0200 Subject: [PATCH 1/8] Add the health-score record table The score is computed live from today's rows, so once the readings behind a past day change the number the person actually saw that day is gone. Nothing wrote it down. This adds the table that does: one row per account per local day, holding the composite, its band, the algorithm version, the composition that produced it, every counted pillar's own score, and a fingerprint over exactly what those depend on. Append-only by construction. A unique index on account plus local day gives the writer something to conflict against, and there is no updated_at, because a later computation of the same day must leave the earlier row exactly as it stood. --- .../0290_health_score_record/migration.sql | 56 +++++++++++++ prisma/schema.prisma | 82 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 prisma/migrations/0290_health_score_record/migration.sql 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..6bcd13b9b --- /dev/null +++ b/prisma/migrations/0290_health_score_record/migration.sql @@ -0,0 +1,56 @@ +-- 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, so a row can never carry a band no surface knows +-- how to render. +-- +-- 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 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" VARCHAR(8) 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_band_check" + CHECK ("band" IN ('green', 'yellow', 'red')), + CONSTRAINT "health_score_records_composite_range_check" + CHECK ("composite" >= 0 AND "composite" <= 100), + CONSTRAINT "health_score_records_day_key_check" + CHECK ("day_key" ~ '^\d{4}-\d{2}-\d{2}$'), + CONSTRAINT "health_score_records_composition_not_empty_check" + CHECK (array_length("composition", 1) >= 1) +); + +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 6cd6fa6a8..8d7af385f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -846,6 +846,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. @@ -5050,6 +5054,84 @@ model UserAchievement { @@map("user_achievements") } +// ─── Health Score record ────── + +/// 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 (`green` / `yellow` / `red`). 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 String @db.VarChar(8) + /// 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. Written by + /// whoever computes the score; today nothing supplies them, because the + /// per-user configuration itself lands beside this table and its delta + /// guard 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 instead of narrating a settings action 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 From 860a1fa299364f74d59c6de3cd35f358d20d5100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:05:51 +0200 Subject: [PATCH 2/8] Record each local day's score on the path that computes it Every surface that shows a score now goes through one entry point that computes it and writes the day down, rather than three surfaces each remembering to. The write is an insert that yields on conflict, so a second computation of the same day leaves the first row alone, and a failure is annotated rather than thrown: a score that renders must not disappear because a bookkeeping insert did. The fingerprint covers exactly what the stored composite and band depend on - the algorithm version, the composition, and each counted pillar's score with its scoring-mode identity. A pillar that was graded but not counted stays out of both the fingerprint and the row, because it did not contribute to the number being stored. --- src/app/api/analytics/route.ts | 4 +- .../analytics/score/__tests__/record.test.ts | 355 ++++++++++++++++++ src/lib/analytics/score/derived.ts | 4 +- src/lib/analytics/score/record.ts | 303 +++++++++++++++ src/lib/dashboard/snapshot.ts | 4 +- 5 files changed, 664 insertions(+), 6 deletions(-) create mode 100644 src/lib/analytics/score/__tests__/record.test.ts create mode 100644 src/lib/analytics/score/record.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index ca9b9c5ad..b08795389 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/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts new file mode 100644 index 000000000..11b61c1f2 --- /dev/null +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -0,0 +1,355 @@ +/** + * 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 { 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("lists every band the composite can produce, and nothing else", () => { + // The migration's CHECK constraint spells out the same three. A band the + // composite can reach and the column refuses is a runtime constraint + // violation on a write path that swallows its own failures, so the two + // lists have to be the same list. + 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 moves", () => { + const narrowed = { + ...base, + composition: ["BLOOD_PRESSURE", "SLEEP"] as ScorePillarId[], + }; + expect(healthScoreInputFingerprint(narrowed)).not.toBe( + healthScoreInputFingerprint(base), + ); + }); + + 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 () => ({ 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 4ae00e227..2b801f0a9 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; @@ -50,7 +50,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..fbfff908e --- /dev/null +++ b/src/lib/analytics/score/record.ts @@ -0,0 +1,303 @@ +/** + * 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, the composition, and each counted + * pillar's score together with its scoring-mode identity. Drop any one of + * them and two genuinely different standings collapse onto one fingerprint — + * a key missing a dimension its value depends on, which is a defect class + * this codebase has shipped before. + * + * 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, + composition: [...input.composition], + 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 f45bde80a..b4a8f81b2 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, @@ -736,7 +736,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, From d89b90da4bd61ee661edbbc3707f33f049af7246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:07:35 +0200 Subject: [PATCH 3/8] Hash what the score actually depends on, and nothing that cannot move The fingerprint carried the composition twice: once as its own key and once as the ordered pillar ids inside the per-pillar triples. No input could make those two disagree, so the term could not be broken and the test that claimed to cover it passed with the term removed. Dropped the duplicate and replaced the test with one that pins what the ids are for: a different set of pillars producing the same numbers in the same order is a different standing, and now reads as one. --- .../analytics/score/__tests__/record.test.ts | 29 ++++++++++++++++++- src/lib/analytics/score/record.ts | 17 +++++++---- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 11b61c1f2..eba7ea28a 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -152,7 +152,7 @@ describe("input fingerprint", () => { ); }); - it("moves when the composition moves", () => { + it("moves when the composition narrows", () => { const narrowed = { ...base, composition: ["BLOOD_PRESSURE", "SLEEP"] as ScorePillarId[], @@ -162,6 +162,33 @@ describe("input fingerprint", () => { ); }); + 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, diff --git a/src/lib/analytics/score/record.ts b/src/lib/analytics/score/record.ts index fbfff908e..930829612 100644 --- a/src/lib/analytics/score/record.ts +++ b/src/lib/analytics/score/record.ts @@ -132,11 +132,17 @@ function countedPillars( * 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, the composition, and each counted - * pillar's score together with its scoring-mode identity. Drop any one of - * them and two genuinely different standings collapse onto one fingerprint — - * a key missing a dimension its value depends on, which is a defect class - * this codebase has shipped before. + * "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. @@ -153,7 +159,6 @@ export function healthScoreInputFingerprint(input: { const canonical = JSON.stringify({ v: FINGERPRINT_VERSION, scoreVersion: input.scoreVersion, - composition: [...input.composition], pillars: counted.map((pillar) => [ pillar.id, pillar.score, From ab8e9a164def281f1b13a4d94b9d6cb89649e701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:13:47 +0200 Subject: [PATCH 4/8] Carry the recorded score days through wipe, backup and restore A new user-scoped table is outside all three by default, and the two structural guards said so the moment the model landed: the wipe plan and the backup verdict list both named it as unclassified. It now joins the wipe list, rides the backup payload as its own spread section, and comes back through the restore route. The classification guards only prove a helper exists in a declared file, so they would pass an unreferenced restore helper. The integration round trip goes through the real admin restore route instead, with the table emptied first so a restore that carries nothing cannot pass by leaving what was already there. --- .../api/admin/backups/[id]/restore/route.ts | 11 + .../analytics/score/__tests__/record.test.ts | 4 +- src/lib/data-wipe/wipe-plan.ts | 6 + src/lib/export/backup-plan.ts | 9 + src/lib/export/full-backup-payload.ts | 20 +- src/lib/export/health-score-backup.ts | 206 +++++++++ src/lib/validations/backup.ts | 31 ++ tests/integration/health-score-record.test.ts | 407 ++++++++++++++++++ 8 files changed, 692 insertions(+), 2 deletions(-) create mode 100644 src/lib/export/health-score-backup.ts create mode 100644 tests/integration/health-score-record.test.ts 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/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index eba7ea28a..96842301d 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -324,7 +324,9 @@ describe("writing the row", () => { * not a comment. */ function delegate(count: number) { - const createMany = vi.fn(async () => ({ count })); + const createMany = vi.fn(async (_args: { skipDuplicates?: boolean }) => ({ + count, + })); return { db: { healthScoreRecord: { createMany } } as never, createMany, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 1e631c218..d5b5003bc 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/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..c8b7f9985 --- /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 as ScoreBand, + 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); + }); +}); From cd7c6f44c8b6d4386eae73f41d25fb2fd0ad2460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:31:47 +0200 Subject: [PATCH 5/8] Teach the export test mocks about the recorded score days Four suites hand-roll a Prisma stand-in for the backup builder, so a new section reaches them as an undefined delegate rather than as an empty result. Each gains the delegate, and the admin restore round trip gains an assertion that the day comes back with its composition and per-pillar scores, not just its headline: a row restored without those looks restored and answers nothing. --- .../[id]/restore/__tests__/round-trip.test.ts | 61 +++++++++++++++++++ .../export/__tests__/per-type-routes.test.ts | 2 + .../export/encrypted/__tests__/route.test.ts | 1 + .../__tests__/full-backup-payload.test.ts | 1 + 4 files changed, 65 insertions(+) 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/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/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. From 5fd544706988eb12b96e046c9a45ef31d681dd08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:38:50 +0200 Subject: [PATCH 6/8] Make the band a database type instead of a text check The delete-all-data guard seeds every table straight from information_schema so a table added next year is asserted without anyone remembering. A CHECK constraint defeats that: the generic seeder has no way to guess a value the constraint accepts, and the whole suite failed on the new table. A Postgres enum carries the same closed set, the seeder reads it from pg_enum, and Prisma now types the column instead of trusting a string. Dropped the day-key format and non-empty-composition checks with it. The writer builds the key through the timezone helper and refuses an empty composition before inserting, and the restore validates both against the wire schema before the transaction opens, so a third copy in the database bought nothing and cost a hand-written line in a fixture whose point is not having one. The composite range check stays: free to check, and a number outside nought to a hundred renders as a score nobody can read. --- .../0290_health_score_record/migration.sql | 27 ++++++++++++------- prisma/schema.prisma | 20 ++++++++++---- .../analytics/score/__tests__/record.test.ts | 16 ++++++++--- src/lib/export/health-score-backup.ts | 2 +- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/prisma/migrations/0290_health_score_record/migration.sql b/prisma/migrations/0290_health_score_record/migration.sql index 6bcd13b9b..6796babd6 100644 --- a/prisma/migrations/0290_health_score_record/migration.sql +++ b/prisma/migrations/0290_health_score_record/migration.sql @@ -10,20 +10,33 @@ -- * 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, so a row can never carry a band no surface knows --- how to render. +-- * 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" VARCHAR(8) NOT NULL, + "band" "health_score_band" NOT NULL, "score_version" INTEGER NOT NULL, "composition" TEXT[] NOT NULL, "pillar_scores" JSONB NOT NULL, @@ -34,14 +47,8 @@ CREATE TABLE "health_score_records" ( "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "health_score_records_pkey" PRIMARY KEY ("id"), - CONSTRAINT "health_score_records_band_check" - CHECK ("band" IN ('green', 'yellow', 'red')), CONSTRAINT "health_score_records_composite_range_check" - CHECK ("composite" >= 0 AND "composite" <= 100), - CONSTRAINT "health_score_records_day_key_check" - CHECK ("day_key" ~ '^\d{4}-\d{2}-\d{2}$'), - CONSTRAINT "health_score_records_composition_not_empty_check" - CHECK (array_length("composition", 1) >= 1) + CHECK ("composite" >= 0 AND "composite" <= 100) ); CREATE UNIQUE INDEX "health_score_records_user_id_day_key_key" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d7af385f..8ea9df74b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5056,6 +5056,17 @@ model UserAchievement { // ─── 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 @@ -5084,11 +5095,10 @@ model HealthScoreRecord { /// The composite 0-100 as shown. Integer, matching the composite's own /// rounding. composite Int - /// The band as shown (`green` / `yellow` / `red`). 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 String @db.VarChar(8) + /// 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 diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 96842301d..3f95d979e 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -9,6 +9,7 @@ */ 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"; @@ -114,11 +115,18 @@ const HEALTHY = [ ]; 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", () => { - // The migration's CHECK constraint spells out the same three. A band the - // composite can reach and the column refuses is a runtime constraint - // violation on a write path that swallows its own failures, so the two - // lists have to be the same list. const produced = new Set(); for (const score of [95, 85, 65, 55, 40, 20, 0]) { const built = report([ diff --git a/src/lib/export/health-score-backup.ts b/src/lib/export/health-score-backup.ts index c8b7f9985..a0a70a0b8 100644 --- a/src/lib/export/health-score-backup.ts +++ b/src/lib/export/health-score-backup.ts @@ -111,7 +111,7 @@ export async function buildHealthScoreBackupSection( dayKey: row.dayKey, timezone: row.timezone, composite: row.composite, - band: row.band as ScoreBand, + band: row.band, scoreVersion: row.scoreVersion, composition: row.composition, pillarScores: readPillarScores(row.pillarScores), From 54fed258597735b71c44763c45118e83049e3e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:39:24 +0200 Subject: [PATCH 7/8] Silence an unused-parameter warning in the record write test --- src/lib/analytics/score/__tests__/record.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 3f95d979e..be4572072 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -332,9 +332,10 @@ describe("writing the row", () => { * not a comment. */ function delegate(count: number) { - const createMany = vi.fn(async (_args: { skipDuplicates?: boolean }) => ({ - count, - })); + const createMany = vi.fn(async (args: { skipDuplicates?: boolean }) => { + void args; + return { count }; + }); return { db: { healthScoreRecord: { createMany } } as never, createMany, From c66aafe9c8949614bb6e9b7a07ca8ec589b11eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:45:22 +0200 Subject: [PATCH 8/8] Read the deferred configuration columns in one line in the audit report The audit script prints the annotation verbatim, so a note wrapped across three schema comment lines came out with the comment markers folded into the middle of the sentence. --- prisma/schema.prisma | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8ea9df74b..a41b30687 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5117,17 +5117,15 @@ model HealthScoreRecord { 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. Written by - /// whoever computes the score; today nothing supplies them, because the - /// per-user configuration itself lands beside this table and its delta - /// guard 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 instead of narrating a settings action as - /// a health event. + /// 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. + /// @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