mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
chore(release): v1.28.25 — every integration held to the same standard
Platform-wide hardening: the failure classes found live this week hunted down across every integration. Fitbit carried the same tombstone wedge fixed for Google — probe now matches soft-deleted rows and the update resurrects them; the comment describing a partial unique index that never existed is corrected. Sleep segment ids are stable across re-scoring for Withings (session+segment-start), WHOOP/Polar (one segment per stage, no positional index) and Oura (run-start keyed), each with a session-scoped sweep that soft-deletes rows a re-score orphaned — so legacy and affected nights heal as they are re-read. One platform rule for deleted rows: source-owned readings resurrect on re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV re-imports, Apple stats day-totals); Apple sample-grain deletions stick per the paired-client contract, and the mood bulk route's tombstone match is now a true no-op reported as duplicate. Import counts reconcile against actual createMany counts. Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so an expired connection parks instead of stamping success hourly; backfill and sleep-repair markers stamp only after a clean pass (pg-boss retries are now real); failed measurement writes hold the watermark; no-op updates are skipped; mapping.md tells the current truth. Scale: doctor-report reads only consumed columns and enabled sections; exports and backups keyset-paginate; achievements tally via SQL buckets; series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear two-pointer; distinct-type scans run server-side; the graded-series cold fallback is bounded; the fitbit defer tracker loops instead of spreading.
This commit is contained in:
1 parent
1085a1e056
commit
9c63d3ce20
79 files changed
+3221
-564
No files matched your search
@@ -17,6 +17,9 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// v1.28.25 — the achievements vitals read is a raw (day, hour, type)
|
||||
// bucket aggregation.
|
||||
$queryRaw: vi.fn(),
|
||||
measurement: { findMany: vi.fn(), count: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() },
|
||||
@@ -31,6 +34,14 @@ vi.mock("@/lib/db", () => ({
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
auditEvent: { findMany: vi.fn() },
|
||||
// v1.28.25 — the achievements aggregation previously aborted mid-flight
|
||||
// on this mock (no `auditLog` model), which the old assertion masked
|
||||
// because the vitals findMany fired before the abort. With the vitals
|
||||
// read on `$queryRaw`, the sleep findMany the assertion now pins runs
|
||||
// AFTER these models — mock them so the aggregation completes.
|
||||
auditLog: { findMany: vi.fn() },
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -95,6 +106,7 @@ beforeEach(() => {
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
});
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
@@ -107,6 +119,12 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
@@ -154,5 +172,14 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
where: expect.objectContaining({ deletedAt: null }),
|
||||
});
|
||||
}
|
||||
// v1.28.25 — the vitals read moved to a raw (day, hour, type) bucket
|
||||
// aggregation; the soft-delete scope must survive on that path too.
|
||||
const rawCalls = vi.mocked(prisma.$queryRaw).mock.calls;
|
||||
expect(rawCalls.length).toBeGreaterThan(0);
|
||||
for (const [strings] of rawCalls) {
|
||||
expect((strings as unknown as readonly string[]).join("?")).toContain(
|
||||
`"deleted_at" IS NULL`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
|
||||
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
|
||||
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
@@ -49,9 +50,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const [measurements, userTz, sourcePriorityJson, profile] = await Promise.all(
|
||||
[
|
||||
prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
// v1.28.25 — keyset-paginated read with a narrow select. The route
|
||||
// keeps its contract (an absent `since`/`until` means the full
|
||||
// history), but the read is now chunked so an unbounded window on a
|
||||
// CGM / per-sample-HR account never materialises the whole table in
|
||||
// one query. The select is exactly the formatter's `ExportMeasurement`
|
||||
// shape + note decryption + the `id` cursor key.
|
||||
findMeasurementsPaged(prisma, where, {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
glucoseContext: true,
|
||||
sleepStage: true,
|
||||
deviceType: true,
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatMoodEntriesForExport,
|
||||
} from "@/lib/export";
|
||||
import { shapeMeasurementNotes, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
@@ -59,13 +60,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (type === "measurements" || type === "all") {
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
// v1.28.25 — keyset-paginated read with a narrow select. The legacy
|
||||
// export has no date window, so a CGM / per-sample-HR account holds a
|
||||
// six-figure row set; one unbounded full-width `findMany` here was the
|
||||
// scale hazard. The select carries exactly the fields the formatter +
|
||||
// note decryption consume (`ExportMeasurement` + `shapeMeasurementNotes`
|
||||
// + the `id` cursor key) — no Bytes column beyond the note ciphertext
|
||||
// the export must decrypt.
|
||||
const measurements = await findMeasurementsPaged(
|
||||
prisma,
|
||||
// v1.4.41 W-DELETED-2 — exclude soft-deleted measurements from
|
||||
// the legacy /api/export endpoint so CSV + JSON downloads stay
|
||||
// consistent with the live measurement reads.
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
});
|
||||
{ userId, deletedAt: null },
|
||||
{
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
glucoseContext: true,
|
||||
sleepStage: true,
|
||||
deviceType: true,
|
||||
},
|
||||
);
|
||||
// v1.11.5 — sleep collapses to one row per night by default (the
|
||||
// formatter's `night` granularity), matching the per-type CSV route.
|
||||
// Thread the user's source-priority so a multi-source night dedups to
|
||||
|
||||
@@ -3,6 +3,9 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// v1.28.25 — the achievements vitals read is a raw (day, hour, type)
|
||||
// bucket aggregation.
|
||||
$queryRaw: vi.fn(),
|
||||
measurement: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
@@ -84,6 +87,7 @@ beforeEach(() => {
|
||||
// v1.4.34 IW-G — reset achievement LRU between tests so each case
|
||||
// observes a cold cache.
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
|
||||
@@ -3,6 +3,9 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// v1.28.25 — the achievements vitals read is a raw (day, hour, type)
|
||||
// bucket aggregation.
|
||||
$queryRaw: vi.fn(),
|
||||
measurement: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
@@ -70,6 +73,7 @@ beforeEach(() => {
|
||||
// v1.4.34 IW-G — reset achievement LRU between tests so each case
|
||||
// observes a cold cache.
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
|
||||
@@ -16,6 +16,9 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// v1.28.25 — the achievements vitals read is a raw (day, hour, type)
|
||||
// bucket aggregation.
|
||||
$queryRaw: vi.fn(),
|
||||
measurement: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
@@ -76,6 +79,7 @@ const SESSION_OK = {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
@@ -123,6 +127,7 @@ describe("GET /api/gamification/achievements — achievements module gate", () =
|
||||
expect(body.meta.module).toBe("achievements");
|
||||
|
||||
// The disappearance is total: no badge aggregation ran.
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.userAchievement.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.userAchievement.createMany).not.toHaveBeenCalled();
|
||||
|
||||
@@ -55,6 +55,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
const imported = await syncUserGoogleHealth(user.id, { fullSync });
|
||||
const { imported } = await syncUserGoogleHealth(user.id, { fullSync });
|
||||
return apiSuccess({ imported, fullSync });
|
||||
});
|
||||
@@ -103,7 +103,11 @@ beforeEach(() => {
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
measurement.findMany.mockResolvedValue([]);
|
||||
measurement.createMany.mockResolvedValue({ count: 0 });
|
||||
// Echo the attempted chunk size, matching Postgres semantics when no row
|
||||
// collides — the route now reconciles `inserted` against this count.
|
||||
measurement.createMany.mockImplementation(
|
||||
async (arg: { data: unknown[] }) => ({ count: arg.data.length }),
|
||||
);
|
||||
measurement.updateMany.mockResolvedValue({ count: 0 });
|
||||
(
|
||||
prisma.$transaction as unknown as {
|
||||
@@ -226,6 +230,29 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resurrects a tombstoned externalId row on re-import (deletedAt: null in update)", async () => {
|
||||
// The ext-probe is deliberately deletedAt-less, so a tombstoned IMPORT
|
||||
// row matches like a live one; the update must carry the resurrection
|
||||
// (IMPORT rows are re-importable by design).
|
||||
mMeasurement().findMany.mockResolvedValue([
|
||||
{ type: "WEIGHT", externalId: "ext-tomb" },
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[HEADER, "WEIGHT,81.0,kg,2026-05-01T08:00:00Z,,,ext-tomb"].join("\n"),
|
||||
),
|
||||
);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.updated).toBe(1);
|
||||
expect(body.data?.rows[0].status).toBe("updated");
|
||||
const updateArg = mMeasurement().updateMany.mock.calls[0][0] as {
|
||||
data: { value: number; deletedAt: Date | null };
|
||||
};
|
||||
expect(updateArg.data.value).toBe(81.0);
|
||||
expect(updateArg.data.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("inserts an externalId row when it does not exist yet", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([]);
|
||||
const res = await POST(
|
||||
@@ -274,4 +301,33 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
// Only the first survivor reaches the bulk insert.
|
||||
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reconciles `inserted` against the createMany count when skipDuplicates absorbs a race", async () => {
|
||||
// Two fresh rows attempted, but a concurrent double-submit already
|
||||
// landed one of them — `skipDuplicates` absorbs the conflict and the
|
||||
// count comes back short. The envelope must sum to the DB truth: one
|
||||
// inserted, one downgraded to skipped/duplicate.
|
||||
mMeasurement().createMany.mockImplementation(
|
||||
async (arg: { data: unknown[] }) => ({ count: arg.data.length - 1 }),
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[
|
||||
HEADER,
|
||||
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,",
|
||||
"WEIGHT,81.5,kg,2026-05-01T09:00:00Z,,,",
|
||||
].join("\n"),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.skipped).toBe(1);
|
||||
const statuses = body.data?.rows.map((r) => r.status).sort();
|
||||
expect(statuses).toEqual(["inserted", "skipped"]);
|
||||
expect(body.data?.rows.find((r) => r.status === "skipped")?.reason).toBe(
|
||||
"duplicate",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -239,12 +239,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
...plainInserts.map(buildCreateData),
|
||||
];
|
||||
const CREATE_CHUNK = 200;
|
||||
let createdCount = 0;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < toCreate.length; i += CREATE_CHUNK) {
|
||||
await tx.measurement.createMany({
|
||||
const res = await tx.measurement.createMany({
|
||||
data: toCreate.slice(i, i + CREATE_CHUNK),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
createdCount += res.count;
|
||||
}
|
||||
for (const m of extUpdates.values()) {
|
||||
await tx.measurement.updateMany({
|
||||
@@ -262,11 +264,37 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// No-op on a live row, a deliberate RESURRECTION on a
|
||||
// tombstoned one — IMPORT rows are re-importable by design, so
|
||||
// a re-imported externalId brings the row back (mirrors the
|
||||
// source-owned sync resurrect rule).
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reconcile `inserted` against what `createMany` actually wrote
|
||||
// (mirrors the batch route's raced-duplicate downgrade). Under
|
||||
// `skipDuplicates` a conflicting row is silently absorbed — the key is
|
||||
// present in the table either way, so we cannot identify the SPECIFIC
|
||||
// raced lines; we only need the counters and per-line statuses to sum
|
||||
// to the truth. Downgrade enough `inserted` lines to `duplicate` so
|
||||
// the envelope matches the DB write count.
|
||||
const racedDuplicates = toCreate.length - createdCount;
|
||||
if (racedDuplicates > 0) {
|
||||
let downgraded = 0;
|
||||
for (const [line, outcome] of writeOutcome) {
|
||||
if (downgraded >= racedDuplicates) break;
|
||||
if (outcome === "inserted") {
|
||||
writeOutcome.set(line, "duplicate");
|
||||
inserted--;
|
||||
skipped++;
|
||||
downgraded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
|
||||
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks.
|
||||
// Best-effort: a populator hiccup never fails the importer.
|
||||
|
||||
@@ -104,6 +104,10 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
|
||||
// enqueue pipeline itself is covered by the comprehensive-generate tests.
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
enqueueStatusRefillForUser: vi.fn(async () => 7),
|
||||
// v1.28.25 — the route shares the lib's comparison-snapshot builder
|
||||
// (its private near-copy was deleted). Null = comparison toggle off,
|
||||
// matching the pre-existing fixtures (no dashboardWidgetsJson set).
|
||||
buildComparisonSnapshotForUser: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
|
||||
@@ -30,10 +30,7 @@ import {
|
||||
buildDerivedBriefingPrompt,
|
||||
} from "@/lib/insights/derived-briefing";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import {
|
||||
buildUserPrompt,
|
||||
type ComparisonSnapshot,
|
||||
} from "@/lib/ai/prompts/insight-system-prompt";
|
||||
import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt";
|
||||
import {
|
||||
buildSystemPromptWithReferences,
|
||||
buildBriefingPersonalisationBlock,
|
||||
@@ -43,18 +40,7 @@ import {
|
||||
getSelfContextTextForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
type ComparisonBaseline,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
insightResultSchema,
|
||||
singleUserTurn,
|
||||
@@ -88,7 +74,13 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
|
||||
// v1.28.25 — the comparison-snapshot builder was a private near-copy of
|
||||
// the lib export (drifted only in comments); the route now shares the
|
||||
// one implementation the nightly warm pass uses.
|
||||
import {
|
||||
buildComparisonSnapshotForUser,
|
||||
enqueueStatusRefillForUser,
|
||||
} from "@/lib/insights/comprehensive-generate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -120,153 +112,6 @@ export function resolveInsightsRateLimit(): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B8 — fetch the user's persisted comparison toggle and
|
||||
* build a `ComparisonSnapshot` for the prompt builder. Returns null
|
||||
* when comparison is off (most users), so the prompt builder skips
|
||||
* the context block entirely.
|
||||
*
|
||||
* Snapshot construction reuses the analytics `summarize()` helper
|
||||
* (which now emits `avg30LastMonth` + `avg30LastYear`) so the numbers
|
||||
* the LLM sees match the numbers the dashboard tile renders — no
|
||||
* second source of truth for the comparison delta.
|
||||
*/
|
||||
async function buildComparisonSnapshotForUser(
|
||||
userId: string,
|
||||
): Promise<ComparisonSnapshot | null> {
|
||||
// The features payload doesn't carry prior-period values — pulling
|
||||
// a fresh `summarize()` per metric below keeps the snapshot
|
||||
// self-contained without retrofitting features.ts.
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
const baseline: ComparisonBaseline = layout.comparisonBaseline ?? "none";
|
||||
if (baseline === "none") return null;
|
||||
|
||||
// Pull every measurement type once and run summarize() so the
|
||||
// prior-period averages line up with what the dashboard analytics
|
||||
// computes. Only include rows where the LLM can sensibly narrate
|
||||
// a prior comparison (i.e. the metric exists in our snapshot type
|
||||
// mapping below).
|
||||
const typeToSnapshotKey: Record<string, string> = {
|
||||
WEIGHT: "weight",
|
||||
BLOOD_PRESSURE_SYS: "bloodPressureSys",
|
||||
BLOOD_PRESSURE_DIA: "bloodPressureDia",
|
||||
PULSE: "pulse",
|
||||
BODY_FAT: "bodyFat",
|
||||
SLEEP_DURATION: "sleep",
|
||||
ACTIVITY_STEPS: "steps",
|
||||
};
|
||||
const typeUnits: Record<string, string> = {
|
||||
WEIGHT: "kg",
|
||||
BLOOD_PRESSURE_SYS: "mmHg",
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "h",
|
||||
ACTIVITY_STEPS: "",
|
||||
};
|
||||
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
|
||||
// The snapshot only feeds summarize()'s avg30 / avg30LastMonth /
|
||||
// avg30LastYear. The widest of those reaches back into the [365, 395)-day
|
||||
// window (ageMs < 395 * DAY in meanOfWindow), so nothing older than 395
|
||||
// days can change a result. Bound the read at 400 days — a 5-day floor over
|
||||
// the strict-less-than boundary — instead of scanning the full history,
|
||||
// which on multi-year accounts is tens of thousands of rows per type on the
|
||||
// page-blocking generate path and the nightly pregenerate cron.
|
||||
const sinceMeasuredAt = new Date(Date.now() - 400 * 86_400_000);
|
||||
const rows = await Promise.all(
|
||||
types.map(async (type) => {
|
||||
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
|
||||
// raw stage rows mislabels a per-stage average as a night total (and
|
||||
// double-counts a bare ASLEEP aggregate against its granular twin). Route
|
||||
// the comparison through the per-night dedup reconstruction so the
|
||||
// current/baseline averages are per-night TIME-ASLEEP totals in minutes.
|
||||
let dataPoints: DataPoint[];
|
||||
if (type === "SLEEP_DURATION") {
|
||||
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: sinceMeasuredAt },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
measuredAt: true,
|
||||
value: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
// Writer-level collapse: two HealthKit apps behind one
|
||||
// source (watch stages vs phone in-bed) must not blend.
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
resolveUserTimezone(userId),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
dataPoints = reconstructSleepNights(
|
||||
sleepRows as SleepStageRow[],
|
||||
sleepTz,
|
||||
sleepPriority,
|
||||
)
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
|
||||
} else {
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: sinceMeasuredAt },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
dataPoints = measurements.map((m): DataPoint => ({
|
||||
date: m.measuredAt,
|
||||
value: m.value,
|
||||
}));
|
||||
}
|
||||
const summary = summarize(dataPoints);
|
||||
const baselineAvg =
|
||||
baseline === "lastMonth"
|
||||
? (summary.avg30LastMonth ?? null)
|
||||
: (summary.avg30LastYear ?? null);
|
||||
const currentAvg = summary.avg30 ?? null;
|
||||
const delta =
|
||||
currentAvg !== null && baselineAvg !== null
|
||||
? Math.round((currentAvg - baselineAvg) * 100) / 100
|
||||
: null;
|
||||
const deltaPercent =
|
||||
delta !== null && baselineAvg !== null && baselineAvg !== 0
|
||||
? Math.round((delta / Math.abs(baselineAvg)) * 100 * 10) / 10
|
||||
: null;
|
||||
return {
|
||||
type: typeToSnapshotKey[type] ?? type,
|
||||
currentAvg,
|
||||
baselineAvg,
|
||||
delta,
|
||||
deltaPercent,
|
||||
unit: typeUnits[type] ?? "",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Drop fully-empty rows (current AND baseline missing) so the prompt
|
||||
// doesn't pad with noise. Keep partial rows (one side null) — the
|
||||
// block renderer flags them with "no prior-period data available"
|
||||
// which is useful context for the model.
|
||||
const metrics = rows.filter(
|
||||
(row) => row.currentAvg !== null || row.baselineAvg !== null,
|
||||
);
|
||||
|
||||
return { baseline, metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/insights/generate — read-only advisor read.
|
||||
*
|
||||
|
||||
@@ -145,6 +145,9 @@ beforeEach(() => {
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
// v1.28.25 — the latest-ever-per-type read is a raw `DISTINCT ON`
|
||||
// (Prisma's `distinct` dedups client-side after pulling every row).
|
||||
(prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
@@ -291,36 +294,33 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
expect(moodTarget).toBeUndefined();
|
||||
});
|
||||
|
||||
it("floors the `findMany distinct` latest-ever-per-type read at one year", async () => {
|
||||
// Audit High finding 3: the `findMany({orderBy: measuredAt desc,
|
||||
// distinct: ["type"]})` for "absolute latest measurement per type"
|
||||
// used to run WITHOUT a `measuredAt >=` floor. On a 347 k-row
|
||||
// tenant Postgres still has to sort the full set before applying
|
||||
// DISTINCT ON because Prisma's `distinct` does not compile to PG's
|
||||
// `DISTINCT ON` — it dedups in the driver. v1.4.40 adds a
|
||||
// 365-day floor so the planner-side walk is bounded.
|
||||
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
|
||||
it("reads latest-ever-per-type via raw DISTINCT ON with the one-year floor", async () => {
|
||||
// Audit High finding 3 established the 365-day floor; v1.28.25 moves
|
||||
// the read onto a real Postgres `DISTINCT ON` (Prisma's `distinct`
|
||||
// dedups in the driver AFTER pulling every row in the window, which
|
||||
// on a dense-type tenant shipped a year of raw rows to Node just to
|
||||
// keep seven). Pin the raw query: DISTINCT ON, per-type descending
|
||||
// walk, user id + floored date as bound parameters.
|
||||
await callGet(makeReq());
|
||||
|
||||
// Find the `distinct: ["type"]` call.
|
||||
const calls = (
|
||||
prisma.measurement.findMany as ReturnType<typeof vi.fn>
|
||||
).mock.calls.map((c) => c[0]);
|
||||
const distinctCall = calls.find(
|
||||
(c) =>
|
||||
c &&
|
||||
Array.isArray(c.distinct) &&
|
||||
c.distinct[0] === "type" &&
|
||||
c.orderBy?.measuredAt === "desc",
|
||||
const rawCalls = (prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mock
|
||||
.calls;
|
||||
const distinctCall = rawCalls.find(
|
||||
(c) => typeof c[0] === "string" && c[0].includes("DISTINCT ON"),
|
||||
);
|
||||
expect(distinctCall).toBeDefined();
|
||||
// The `measuredAt >= oneYearAgo` floor must be present.
|
||||
expect(distinctCall?.where?.measuredAt).toBeDefined();
|
||||
expect(distinctCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
|
||||
const flooredAt = distinctCall?.where?.measuredAt?.gte as Date;
|
||||
const [sql, boundUserId, flooredAt] = distinctCall as [
|
||||
string,
|
||||
string,
|
||||
Date,
|
||||
];
|
||||
expect(sql).toContain(`DISTINCT ON (m."type")`);
|
||||
expect(sql).toContain(`ORDER BY m."type" ASC, m."measured_at" DESC`);
|
||||
// Parameter-bound, never spliced: user id + date ride $1/$2.
|
||||
expect(sql).toContain("$1");
|
||||
expect(sql).toContain("$2");
|
||||
expect(boundUserId).toBe("user-targets-1");
|
||||
expect(flooredAt).toBeInstanceOf(Date);
|
||||
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
|
||||
const ageMs = Date.now() - flooredAt.getTime();
|
||||
// Floor is somewhere around one year ago — give a ±1-hour window
|
||||
|
||||
@@ -133,6 +133,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
source: "MOODLOG",
|
||||
date: entry.date,
|
||||
moodLoggedAt,
|
||||
// No-op on a live row, a deliberate RESURRECTION on a tombstoned
|
||||
// one — moodLog is the source of truth for the rows it minted; a
|
||||
// created/updated event means the entry exists upstream (deletion
|
||||
// arrives as its own `mood.deleted` event).
|
||||
deletedAt: null,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
@@ -160,6 +165,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
score: entry.score,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
source: "MOODLOG",
|
||||
// Same resurrection rule as the externalId-keyed upsert above.
|
||||
deletedAt: null,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -308,3 +308,69 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(data.entries[0].status).toBe("updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — stats tombstone resurrection", () => {
|
||||
it("resurrects a tombstoned stats: day-total on re-post (deletedAt: null, status updated)", async () => {
|
||||
const STEP_STATS_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-06-21";
|
||||
// The existence probe is deliberately deletedAt-less, so a tombstoned
|
||||
// day-total row matches exactly like a live one and routes into the
|
||||
// overwrite branch.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: STEP_STATS_ID,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
||||
value: 9001,
|
||||
unit: "count",
|
||||
startDate: "2026-06-21T00:00:00.000Z",
|
||||
endDate: "2026-06-21T23:59:59.000Z",
|
||||
externalId: STEP_STATS_ID,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await readJson(res);
|
||||
expect(data.updated).toBe(1);
|
||||
expect(data.entries[0].status).toBe("updated");
|
||||
|
||||
// The overwrite carries the resurrection: the observer re-posts the
|
||||
// day's canonical total, so the update data pins `deletedAt: null`.
|
||||
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
|
||||
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
|
||||
const updateData = (
|
||||
updateArg as { data: { value: number; deletedAt: Date | null } }
|
||||
).data;
|
||||
expect(updateData.value).toBe(9001);
|
||||
expect(updateData.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a tombstoned SAMPLE-grain row suppressed (duplicate, no resurrect write)", async () => {
|
||||
// Apple LWW contract: the iOS reconciler propagates HealthKit
|
||||
// deletions for sample-grain rows, so a re-post of a tombstoned
|
||||
// sample stays a checkpointing `duplicate` — no update runs at all.
|
||||
const SAMPLE_ID = "D00DAD00-0000-4000-8000-000000000000";
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "PULSE", source: "APPLE_HEALTH", externalId: SAMPLE_ID },
|
||||
] as never);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ entries: [hrBucketEntry(SAMPLE_ID, 61)] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await readJson(res);
|
||||
expect(data.duplicates).toBe(1);
|
||||
expect(data.updated).toBe(0);
|
||||
expect(data.entries[0].status).toBe("duplicate");
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -545,6 +545,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
string | null,
|
||||
deviceType: p.row.deviceType as Prepared["row"]["deviceType"],
|
||||
sleepStage: p.row.sleepStage as Prepared["row"]["sleepStage"],
|
||||
// No-op on a live row, a deliberate RESURRECTION on a
|
||||
// tombstoned one — the observer re-posts the day's canonical
|
||||
// total, so a `stats:` day-total row follows the
|
||||
// source-owned resurrect rule (mirrors Google / Fitbit).
|
||||
// SAMPLE-grain rows never reach this branch: their
|
||||
// tombstones stick (the iOS LWW reconciler propagates
|
||||
// HealthKit deletions and reports them as `duplicate`).
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
results[p.index] = { index: p.index, status: "updated" };
|
||||
|
||||
@@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({
|
||||
measurement: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
// v1.28.25 — dense kinds (glucose / pulse) day-bucket in SQL for
|
||||
// windows beyond the 90-day raw cap.
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -65,6 +68,7 @@ function req(query: string): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
@@ -122,6 +126,48 @@ describe("GET /api/measurements/series", () => {
|
||||
expect(body.data.stats.count).toBe(1);
|
||||
});
|
||||
|
||||
it("pairs BP via the sorted two-pointer merge exactly like the old scan (v1.28.25)", async () => {
|
||||
// Pins the O(sys + dia) merge against the previous per-systolic full
|
||||
// rescan: nearest diastolic within ±5 min wins, a tie keeps the
|
||||
// EARLIER diastolic row, an out-of-window systolic pairs null.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const at = (iso: string) => new Date(iso);
|
||||
const findManyMock = vi.mocked(prisma.measurement.findMany);
|
||||
findManyMock.mockImplementation(((args: unknown) => {
|
||||
const a = args as { where: { type: string } };
|
||||
if (a.where.type === "BLOOD_PRESSURE_SYS") {
|
||||
return Promise.resolve([
|
||||
{ id: "s1", value: 120, measuredAt: at("2026-05-01T10:00:00Z") },
|
||||
// Equidistant (±2 min) between the 10:03 and 10:07 dia rows.
|
||||
{ id: "s2", value: 124, measuredAt: at("2026-05-01T10:05:00Z") },
|
||||
{ id: "s3", value: 130, measuredAt: at("2026-05-01T10:10:00Z") },
|
||||
// > 5 min from every diastolic row — stays unpaired.
|
||||
{ id: "s4", value: 140, measuredAt: at("2026-05-01T11:00:00Z") },
|
||||
]) as never;
|
||||
}
|
||||
if (a.where.type === "BLOOD_PRESSURE_DIA") {
|
||||
return Promise.resolve([
|
||||
{ id: "d1", value: 80, measuredAt: at("2026-05-01T10:01:00Z") },
|
||||
{ id: "d2", value: 82, measuredAt: at("2026-05-01T10:03:00Z") },
|
||||
{ id: "d3", value: 84, measuredAt: at("2026-05-01T10:07:00Z") },
|
||||
{ id: "d4", value: 86, measuredAt: at("2026-05-01T10:11:00Z") },
|
||||
]) as never;
|
||||
}
|
||||
return Promise.resolve([]) as never;
|
||||
}) as never);
|
||||
const res = await GET(req("kind=bloodPressure&days=30"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { points: Array<{ id: string; secondary: number | null }> };
|
||||
};
|
||||
expect(body.data.points.map((p) => [p.id, p.secondary])).toEqual([
|
||||
["s1", 80], // nearest is d1 (1 min)
|
||||
["s2", 82], // d2/d3 tie at 2 min — earlier row wins
|
||||
["s3", 86], // nearest is d4 (1 min)
|
||||
["s4", null], // nothing within ±5 min
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns single-value series for kind=weight", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
@@ -377,6 +423,184 @@ describe("GET /api/measurements/series", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)", () => {
|
||||
it("day-buckets glucose in SQL beyond the 90-day raw window", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
// 1st query — day buckets.
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
bucket_start: new Date("2025-01-01T00:00:00Z"),
|
||||
mean: 100,
|
||||
min_value: 70,
|
||||
max_value: 180,
|
||||
},
|
||||
{
|
||||
bucket_start: new Date("2025-01-02T00:00:00Z"),
|
||||
mean: 126,
|
||||
min_value: 80,
|
||||
max_value: 200,
|
||||
},
|
||||
] as never)
|
||||
// 2nd query — raw-row aggregate for the stats strip.
|
||||
.mockResolvedValueOnce([
|
||||
{ n: 576, mean: 113.04, min: 70, max: 200, sd: 22.34 },
|
||||
] as never);
|
||||
|
||||
const res = await GET(req("kind=glucose&days=365"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
unit: string;
|
||||
points: Array<{
|
||||
id: string;
|
||||
at: string;
|
||||
value: number;
|
||||
secondary: number | null;
|
||||
}>;
|
||||
stats: {
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
stdDev: number;
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
// No raw row walk — the read is SQL-aggregate only.
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(body.data.unit).toBe("mg/dL");
|
||||
expect(body.data.points).toEqual([
|
||||
{
|
||||
id: "day:2025-01-01",
|
||||
at: "2025-01-01T00:00:00.000Z",
|
||||
value: 100,
|
||||
secondary: null,
|
||||
},
|
||||
{
|
||||
id: "day:2025-01-02",
|
||||
at: "2025-01-02T00:00:00.000Z",
|
||||
value: 126,
|
||||
secondary: null,
|
||||
},
|
||||
]);
|
||||
// Stats come from the RAW-row aggregate (count = raw samples, not
|
||||
// day buckets), matching the raw path's mg/dL parity convention.
|
||||
expect(body.data.stats).toEqual({
|
||||
mean: 113,
|
||||
min: 70,
|
||||
max: 200,
|
||||
stdDev: 22,
|
||||
count: 576,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts day-bucketed glucose for a mmol/L-preference user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
bucket_start: new Date("2025-01-01T00:00:00Z"),
|
||||
mean: 100,
|
||||
min_value: 70,
|
||||
max_value: 180,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ n: 288, mean: 100, min: 70, max: 180, sd: 18 },
|
||||
] as never);
|
||||
|
||||
const res = await GET(req("kind=glucose&days=3650"));
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
unit: string;
|
||||
points: Array<{ value: number }>;
|
||||
stats: { mean: number };
|
||||
};
|
||||
};
|
||||
expect(body.data.unit).toBe("mmol/L");
|
||||
expect(body.data.points[0].value).toBe(5.5); // 100 mg/dL → 5.5 mmol/L
|
||||
expect(body.data.stats.mean).toBe(5.5);
|
||||
});
|
||||
|
||||
it("day-buckets pulse beyond 90 days with the day's min/max band", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
bucket_start: new Date("2025-06-01T00:00:00Z"),
|
||||
mean: 71.666,
|
||||
min_value: 52,
|
||||
max_value: 143,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ n: 24, mean: 71.666, min: 58, max: 132, sd: 9.876 },
|
||||
] as never);
|
||||
|
||||
const res = await GET(req("kind=pulse&days=365"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
points: Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
}>;
|
||||
stats: { mean: number; stdDev: number; count: number };
|
||||
};
|
||||
};
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
expect(body.data.points).toEqual([
|
||||
{
|
||||
id: "day:2025-06-01",
|
||||
at: "2025-06-01T00:00:00.000Z",
|
||||
value: 71.67,
|
||||
secondary: null,
|
||||
valueMin: 52,
|
||||
valueMax: 143,
|
||||
},
|
||||
]);
|
||||
expect(body.data.stats.mean).toBe(71.67);
|
||||
expect(body.data.stats.stdDev).toBe(9.88);
|
||||
expect(body.data.stats.count).toBe(24);
|
||||
});
|
||||
|
||||
it("keeps the 90-day window on the raw path (byte-identical short ranges)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
|
||||
] as never);
|
||||
const res = await GET(req("kind=glucose&days=90"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { points: Array<{ id: string }> };
|
||||
};
|
||||
// Raw read, individual row ids — the SQL bucket path never fires.
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(body.data.points[0].id).toBe("g1");
|
||||
});
|
||||
|
||||
it("keeps sparse kinds raw even on the ten-year window", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "w1", value: 78.4, measuredAt: new Date() },
|
||||
] as never);
|
||||
const res = await GET(req("kind=weight&days=3650"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { points: Array<{ id: string }> };
|
||||
};
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(body.data.points[0].id).toBe("w1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
@@ -35,6 +35,21 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
*/
|
||||
const SLEEP_SERIES_MAX_DAYS = 365;
|
||||
|
||||
/**
|
||||
* v1.28.25 — sample-dense kinds read raw rows only up to this window.
|
||||
* A CGM account stores ~288 BLOOD_GLUCOSE rows/day (105k+ rows/year) and
|
||||
* per-sample PULSE piles up similarly, so the 365 / 3650-day requests
|
||||
* walked a six-figure row set into JS, mapped it twice, and serialized
|
||||
* everything. Mirrors the sleep precedent above, but instead of silently
|
||||
* clamping the window, requests beyond the cap keep their full range and
|
||||
* are day-bucketed in SQL (one point per day, mean + min/max band) — the
|
||||
* chart still shows the whole requested history, just at daily grain.
|
||||
* The short windows users actually browse (7 / 30 / 90 d) stay raw and
|
||||
* byte-identical. Sparse kinds (weight, BP, …) are untouched.
|
||||
*/
|
||||
const DENSE_SERIES_RAW_WINDOW_DAYS = 90;
|
||||
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set(["glucose", "pulse"]);
|
||||
|
||||
const kindEnum = z.enum([
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
@@ -204,7 +219,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.16.16 — when glucose stats are computed over RAW mg/dL (parity with the
|
||||
// detail page + FHIR), the branch fills this so the response below skips the
|
||||
// generic over-points path that would double-round mmol/L figures.
|
||||
let glucoseStats: {
|
||||
// v1.28.25 — the dense day-bucket branch also fills it (stats stay
|
||||
// aggregated over the RAW rows in SQL, not over the day-bucket points).
|
||||
let statsOverride: {
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
@@ -306,24 +323,155 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}),
|
||||
]);
|
||||
|
||||
// Pair by closest timestamp within ±5 minutes.
|
||||
// Pair by closest timestamp within ±5 minutes. Both reads arrive
|
||||
// sorted ascending, so the nearest-diastolic index is non-decreasing
|
||||
// across the systolic walk — a two-pointer merge finds each pair in
|
||||
// O(sys + dia) instead of the previous full diastolic rescan per
|
||||
// systolic row (O(sys × dia) — quadratic on dense imports). Ties keep
|
||||
// the earlier diastolic row, matching the old scan's strict-`<` keep.
|
||||
const PAIR_WINDOW_MS = 5 * 60_000;
|
||||
let diaIdx = 0;
|
||||
points = sys.map((s) => {
|
||||
let bestDia: { value: number; dt: number } | null = null;
|
||||
for (const d of dia) {
|
||||
const dt = Math.abs(d.measuredAt.getTime() - s.measuredAt.getTime());
|
||||
if (dt > PAIR_WINDOW_MS) continue;
|
||||
if (!bestDia || dt < bestDia.dt) {
|
||||
bestDia = { value: d.value, dt };
|
||||
}
|
||||
const sysMs = s.measuredAt.getTime();
|
||||
while (
|
||||
diaIdx + 1 < dia.length &&
|
||||
Math.abs(dia[diaIdx + 1].measuredAt.getTime() - sysMs) <
|
||||
Math.abs(dia[diaIdx].measuredAt.getTime() - sysMs)
|
||||
) {
|
||||
diaIdx += 1;
|
||||
}
|
||||
const best = dia[diaIdx];
|
||||
const paired =
|
||||
best !== undefined &&
|
||||
Math.abs(best.measuredAt.getTime() - sysMs) <= PAIR_WINDOW_MS;
|
||||
return {
|
||||
id: s.id,
|
||||
at: s.measuredAt.toISOString(),
|
||||
value: s.value,
|
||||
secondary: bestDia?.value ?? null,
|
||||
secondary: paired ? best.value : null,
|
||||
};
|
||||
});
|
||||
} else if (
|
||||
DENSE_SERIES_KINDS.has(kind) &&
|
||||
days > DENSE_SERIES_RAW_WINDOW_DAYS
|
||||
) {
|
||||
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
|
||||
// per-sample / hourly pulse). Day-bucket in SQL instead of walking
|
||||
// every raw row into JS: one aggregate pass in Postgres returns at
|
||||
// most `days` rows regardless of sample density. Parameter-bound
|
||||
// tagged-template `$queryRaw`, mirroring the rollup tier's
|
||||
// `date_trunc(... ) GROUP BY` aggregate (measurement-rollups.ts).
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
const bucketRows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
bucket_start: Date;
|
||||
mean: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
date_trunc('day', m."measured_at") AS bucket_start,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
MIN(COALESCE(m."value_min", m."value"))::double precision AS min_value,
|
||||
MAX(COALESCE(m."value_max", m."value"))::double precision AS max_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."type" = ${type}::"measurement_type"
|
||||
AND m."measured_at" >= ${since}
|
||||
AND m."deleted_at" IS NULL
|
||||
GROUP BY date_trunc('day', m."measured_at")
|
||||
ORDER BY bucket_start ASC
|
||||
`;
|
||||
// Stats stay aggregated over the RAW rows (not the day buckets) so the
|
||||
// strip's mean/min/max/stdDev/count are the same figures the raw path
|
||||
// computed: `AVG`/`MIN`/`MAX` over `value`, `STDDEV_POP` matching the
|
||||
// JS population-variance helper, exact row count. Aggregate-only —
|
||||
// no row transfer.
|
||||
const [rawAgg] = await prisma.$queryRaw<
|
||||
Array<{
|
||||
n: number;
|
||||
mean: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
sd: number | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
COUNT(*)::int AS n,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
MIN(m."value")::double precision AS min,
|
||||
MAX(m."value")::double precision AS max,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."type" = ${type}::"measurement_type"
|
||||
AND m."measured_at" >= ${since}
|
||||
AND m."deleted_at" IS NULL
|
||||
`;
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
const dayId = (d: Date) => `day:${d.toISOString().slice(0, 10)}`;
|
||||
if (kind === "glucose") {
|
||||
// Same unit engine as the raw glucose branch: canonical mg/dL in
|
||||
// the DB, converted ONCE at serialization to the user's preference.
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { glucoseUnit: true },
|
||||
});
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
unit = glucoseUnit;
|
||||
points = bucketRows.map((r) => ({
|
||||
id: dayId(r.bucket_start),
|
||||
at: r.bucket_start.toISOString(),
|
||||
value: convertGlucose(r.mean, glucoseUnit),
|
||||
secondary: null,
|
||||
}));
|
||||
if (
|
||||
rawAgg !== undefined &&
|
||||
rawAgg.n > 0 &&
|
||||
rawAgg.mean !== null &&
|
||||
rawAgg.min !== null &&
|
||||
rawAgg.max !== null
|
||||
) {
|
||||
// Mirrors the raw branch's v1.16.16 parity: round the mg/dL
|
||||
// aggregate to 2 decimals (summarize()'s convention), then
|
||||
// convert each figure once.
|
||||
statsOverride = {
|
||||
mean: convertGlucose(round2(rawAgg.mean), glucoseUnit),
|
||||
min: convertGlucose(rawAgg.min, glucoseUnit),
|
||||
max: convertGlucose(rawAgg.max, glucoseUnit),
|
||||
stdDev: convertGlucose(round2(rawAgg.sd ?? 0), glucoseUnit),
|
||||
count: rawAgg.n,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// pulse — `value` is the day's average; valueMin/valueMax carry the
|
||||
// day's low/high band (folding each hourly bucket's own spread via
|
||||
// the COALESCE above), same band semantics as the per-hour shape.
|
||||
points = bucketRows.map((r) => ({
|
||||
id: dayId(r.bucket_start),
|
||||
at: r.bucket_start.toISOString(),
|
||||
value: round2(r.mean),
|
||||
secondary: null,
|
||||
valueMin: r.min_value,
|
||||
valueMax: r.max_value,
|
||||
}));
|
||||
if (
|
||||
rawAgg !== undefined &&
|
||||
rawAgg.n > 0 &&
|
||||
rawAgg.mean !== null &&
|
||||
rawAgg.min !== null &&
|
||||
rawAgg.max !== null
|
||||
) {
|
||||
statsOverride = {
|
||||
mean: round2(rawAgg.mean),
|
||||
min: rawAgg.min,
|
||||
max: rawAgg.max,
|
||||
stdDev: round2(rawAgg.sd ?? 0),
|
||||
count: rawAgg.n,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
// v1.19.2 (iOS #34 extension) — pull the per-bucket spread for the
|
||||
@@ -371,7 +519,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// already-converted+rounded points double-rounds the mean and stdDev
|
||||
// for mmol/L users, drifting the last decimal off the detail page.
|
||||
// Single-reading sets stay identical (100 → 5.5). Hand back stats here
|
||||
// so the shared block below leaves a populated `glucoseStats` alone.
|
||||
// so the shared block below leaves a populated `statsOverride` alone.
|
||||
const rawDataPoints: DataPoint[] = rows.map((r) => ({
|
||||
date: r.measuredAt,
|
||||
value: r.value,
|
||||
@@ -379,7 +527,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (rawDataPoints.length > 0) {
|
||||
const rawSummary = summarize(rawDataPoints);
|
||||
const rawValues = rawDataPoints.map((p) => p.value);
|
||||
glucoseStats = {
|
||||
statsOverride = {
|
||||
mean:
|
||||
rawSummary.mean === null
|
||||
? 0
|
||||
@@ -443,7 +591,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
unit,
|
||||
points,
|
||||
stats:
|
||||
glucoseStats ??
|
||||
statsOverride ??
|
||||
(summary
|
||||
? {
|
||||
mean: summary.mean,
|
||||
|
||||
@@ -413,3 +413,71 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
|
||||
expect(json.data.entries[1].status).toBe("inserted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk — tombstone suppression", () => {
|
||||
it("keeps a tombstoned match deleted: true no-op reported as duplicate", async () => {
|
||||
// A soft-deleted row under the probe key: the sync feed already
|
||||
// surfaced the deletion to paired clients, so a stale offline re-post
|
||||
// must NOT resurrect it — and must not churn the hidden row either.
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
|
||||
id: "entry-tomb",
|
||||
deletedAt: new Date("2026-05-01T00:00:00.000Z"),
|
||||
} as never);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
externalId: "ios-uuid-tomb",
|
||||
tagKeys: ["movies"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
entries: Array<{ status: string; id?: string; externalId?: string }>;
|
||||
};
|
||||
};
|
||||
expect(json.data.inserted).toBe(0);
|
||||
expect(json.data.duplicates).toBe(1);
|
||||
expect(json.data.entries[0].status).toBe("duplicate");
|
||||
expect(json.data.entries[0].id).toBe("entry-tomb");
|
||||
expect(json.data.entries[0].externalId).toBe("ios-uuid-tomb");
|
||||
// No write at all on the hidden row: no upsert (value churn /
|
||||
// updatedAt bump), no tag-link insert.
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(createTagLinks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still refreshes a LIVE match in place (duplicate + upsert runs)", async () => {
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
|
||||
id: "entry-live",
|
||||
deletedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
|
||||
id: "entry-live",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
externalId: "ios-uuid-live",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { duplicates: number };
|
||||
};
|
||||
expect(json.data.duplicates).toBe(1);
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -227,9 +227,28 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// grows.
|
||||
const existing = await prisma.moodEntry.findUnique({
|
||||
where: probeWhere,
|
||||
select: { id: true },
|
||||
select: { id: true, deletedAt: true },
|
||||
});
|
||||
|
||||
// Tombstone suppression: a soft-deleted match stays deleted. The
|
||||
// user-facing DELETE route flips `deletedAt` so the `/api/sync/changes`
|
||||
// feed surfaces the deletion to paired clients offline at delete time
|
||||
// (see the MoodEntry schema note) — an offline client's later re-post
|
||||
// of the same entry is stale state, not a recreation, and must not
|
||||
// resurrect the row. True no-op: no value churn, no `updatedAt` bump,
|
||||
// no tag-link writes on the hidden row; report `duplicate` so the
|
||||
// client checkpoints past it exactly like a live-row match.
|
||||
if (existing?.deletedAt) {
|
||||
duplicates += 1;
|
||||
results.push({
|
||||
index: i,
|
||||
status: "duplicate",
|
||||
id: existing.id,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await prisma.moodEntry.upsert({
|
||||
where: probeWhere,
|
||||
create: {
|
||||
|
||||
Reference in new issue
Block a user