mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge the persisted score record into the composition swap
This commit is contained in:
commit
0826bd80e6
18 files changed
+1614
-7
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<string, string>();
|
||||
const restoredBiomarkerIds = new Set<string>();
|
||||
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,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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([]) },
|
||||
|
||||
@@ -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<ScorePillarId, ScorePillarResult["domain"]> = {
|
||||
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<PillarValue> = 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<string>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<ScoreBand, (typeof SCORE_BANDS)[number]> 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<string, number>;
|
||||
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<PrismaClient, "healthScoreRecord">;
|
||||
|
||||
/**
|
||||
* 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<HealthScoreRecordOutcome> {
|
||||
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<HealthScoreRecordContext, "configVersion" | "configChangedAt">,
|
||||
): Promise<HealthScoreReport> {
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
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<string, number> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, number> = {};
|
||||
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<PrismaClient, "healthScoreRecord">,
|
||||
userId: string,
|
||||
options: HealthScoreBackupOptions = {},
|
||||
): Promise<HealthScoreBackupSection> {
|
||||
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<HealthScoreRestoreCleared> {
|
||||
const cleared = await tx.healthScoreRecord.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AnalyticsEnvelope> {
|
||||
const { GET } = await import("@/app/api/analytics/route");
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
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<string, number>).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<typeof POST>[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<Response>
|
||||
)(
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user