mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(api): publish what the GET handlers send
A response parsed against its own registered schema disagreed in thirteen
operations. Where the handler was right, the schema now says so; where the
handler had drifted from its siblings, it follows them.
Schema:
- measurements list and detail declare aggregationProvenance
- series-batch and sleep night stages are partial records: only the
requested types and the recorded stages are present
- medications declare deliveryForm, the injection-site fields, externalId,
externalSource, the low-stock bookkeeping, schedules[].doseWindows and
nextDueScheduleId on the list
- notification prefs declare coach.nudgeAiComposed and measurementReminder
- mood insights correlations declare suppressed
- record settings profile.unitPreference is nullable, as stored
- insights/comprehensive describes the computed overview it returns rather
than the model digest fields it never sends
Handler:
- GET /api/mood-entries/{id} shares the list projection and now carries
tagKeys and ratedFactors
- GET and PATCH /api/custom-metrics/{id} carry latest and entryCount
- /api/analytics no longer leaks anomalyCount through the per-night sleep
summary and the glucose meal-context split
This commit is contained in:
1 parent
67cc56f707
commit
aecf6f91b1
18 files changed
+1214
-283
No files matched your search
+698
-163
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { analyticsQuerySchema } from "@/lib/validations/analytics";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { summarize, wireSummary, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read";
|
||||
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
|
||||
@@ -500,8 +500,10 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
glucoseRows,
|
||||
(r) => r.glucoseContext,
|
||||
)) {
|
||||
glucoseByContext[bucket] = summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
glucoseByContext[bucket] = wireSummary(
|
||||
summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,22 @@ const EXISTING = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* The row as the reads load it: with the newest live entry and the live entry
|
||||
* count joined on, which the published row carries as `latest` and
|
||||
* `entryCount`.
|
||||
*/
|
||||
const JOINS = {
|
||||
entries: [
|
||||
{
|
||||
value: 42.5,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-06-03T07:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
_count: { entries: 7 },
|
||||
};
|
||||
|
||||
const params = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
|
||||
function patchReq(body: unknown): NextRequest {
|
||||
@@ -77,17 +93,54 @@ beforeEach(() => {
|
||||
describe("GET /api/custom-metrics/[id]", () => {
|
||||
it("returns the metric for its owner", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(
|
||||
EXISTING as never,
|
||||
);
|
||||
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({
|
||||
...EXISTING,
|
||||
...JOINS,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/custom-metrics/cm-1"),
|
||||
params("cm-1"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { name: string } | null };
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
name: string;
|
||||
latest: { value: number; measuredAt: string } | null;
|
||||
entryCount: number;
|
||||
} | null;
|
||||
};
|
||||
expect(body.data?.name).toBe("Grip strength");
|
||||
// The published row carries the latest value and the count on its own,
|
||||
// as it does in the catalogue; the read asks for both joins.
|
||||
expect(body.data?.latest).toEqual({
|
||||
value: 42.5,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-06-03T07:00:00.000Z",
|
||||
});
|
||||
expect(body.data?.entryCount).toBe(7);
|
||||
const findCall = vi.mocked(prisma.customMetric.findFirst).mock
|
||||
.calls[0][0] as { include?: Record<string, unknown> };
|
||||
expect(Object.keys(findCall.include ?? {})).toEqual(["entries", "_count"]);
|
||||
});
|
||||
|
||||
it("answers latest null and a zero count for a metric with no entries", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({
|
||||
...EXISTING,
|
||||
entries: [],
|
||||
_count: { entries: 0 },
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/custom-metrics/cm-1"),
|
||||
params("cm-1"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { latest: unknown; entryCount: number };
|
||||
};
|
||||
expect(body.data.latest).toBeNull();
|
||||
expect(body.data.entryCount).toBe(0);
|
||||
});
|
||||
|
||||
it("404s a cross-user metric", async () => {
|
||||
@@ -113,6 +166,7 @@ describe("PATCH /api/custom-metrics/[id]", () => {
|
||||
);
|
||||
vi.mocked(prisma.customMetric.update).mockResolvedValue({
|
||||
...EXISTING,
|
||||
...JOINS,
|
||||
targetLow: 20,
|
||||
targetHigh: 50,
|
||||
} as never);
|
||||
@@ -126,6 +180,12 @@ describe("PATCH /api/custom-metrics/[id]", () => {
|
||||
.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(updateCall.data.targetLow).toBe(20);
|
||||
expect(updateCall.data.targetHigh).toBe(50);
|
||||
// The edit echo is the same published row as the read.
|
||||
const body = (await res.json()) as {
|
||||
data: { entryCount: number; latest: { value: number } | null };
|
||||
};
|
||||
expect(body.data.entryCount).toBe(7);
|
||||
expect(body.data.latest?.value).toBe(42.5);
|
||||
});
|
||||
|
||||
it("persists only the explicit correlation opt-in field", async () => {
|
||||
@@ -135,6 +195,7 @@ describe("PATCH /api/custom-metrics/[id]", () => {
|
||||
);
|
||||
vi.mocked(prisma.customMetric.update).mockResolvedValue({
|
||||
...EXISTING,
|
||||
...JOINS,
|
||||
correlationEnabled: true,
|
||||
} as never);
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetric } from "@/lib/custom-metrics/custom-metric-store";
|
||||
import {
|
||||
CUSTOM_METRIC_LATEST_INCLUDE,
|
||||
serialiseCustomMetricWithLatest,
|
||||
} from "@/lib/custom-metrics/custom-metric-store";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { updateCustomMetricSchema } from "@/lib/validations/custom-metrics";
|
||||
@@ -41,8 +44,11 @@ export const GET = apiHandler(
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
// The same joins as the list read: the published row carries the latest
|
||||
// value and the entry count, on its own as in the catalogue.
|
||||
const row = await prisma.customMetric.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: CUSTOM_METRIC_LATEST_INCLUDE,
|
||||
});
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Custom metric not found", 404);
|
||||
@@ -53,7 +59,13 @@ export const GET = apiHandler(
|
||||
meta: { customMetricId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseCustomMetric(row));
|
||||
return apiSuccess(
|
||||
serialiseCustomMetricWithLatest({
|
||||
...row,
|
||||
latest: row.entries[0] ?? null,
|
||||
entryCount: row._count.entries,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -135,7 +147,11 @@ export const PATCH = apiHandler(
|
||||
return apiError("targetLow must not exceed targetHigh", 422);
|
||||
}
|
||||
|
||||
const updated = await prisma.customMetric.update({ where: { id }, data });
|
||||
const updated = await prisma.customMetric.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: CUSTOM_METRIC_LATEST_INCLUDE,
|
||||
});
|
||||
|
||||
await auditLog("customMetric.update", {
|
||||
userId: user.id,
|
||||
@@ -149,7 +165,13 @@ export const PATCH = apiHandler(
|
||||
});
|
||||
invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
return apiSuccess(serialiseCustomMetric(updated));
|
||||
return apiSuccess(
|
||||
serialiseCustomMetricWithLatest({
|
||||
...updated,
|
||||
latest: updated.entries[0] ?? null,
|
||||
entryCount: updated._count.entries,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store";
|
||||
import {
|
||||
CUSTOM_METRIC_LATEST_INCLUDE,
|
||||
serialiseCustomMetricWithLatest,
|
||||
} from "@/lib/custom-metrics/custom-metric-store";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -34,15 +37,7 @@ export const GET = apiHandler(async () => {
|
||||
const rows = await prisma.customMetric.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
entries: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 1,
|
||||
select: { value: true, unit: true, measuredAt: true },
|
||||
},
|
||||
_count: { select: { entries: { where: { deletedAt: null } } } },
|
||||
},
|
||||
include: CUSTOM_METRIC_LATEST_INCLUDE,
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -68,7 +68,7 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
@@ -138,6 +138,43 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/mood-entries/[id] — the list's wire shape", () => {
|
||||
it("carries the structured tags and rated factors, as the list does", async () => {
|
||||
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue({
|
||||
...EXISTING_ENTRY,
|
||||
tagLinks: [
|
||||
{ rating: null, moodTag: { key: "sleep_good", kind: "BINARY" } },
|
||||
{ rating: 4, moodTag: { key: "energy", kind: "RATED" } },
|
||||
],
|
||||
context: null,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/mood-entries/me1"),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
tagKeys: string[];
|
||||
ratedFactors: Array<{ key: string; rating: number }>;
|
||||
tagLinks?: unknown;
|
||||
};
|
||||
};
|
||||
expect(body.data.tagKeys).toEqual(["sleep_good"]);
|
||||
expect(body.data.ratedFactors).toEqual([{ key: "energy", rating: 4 }]);
|
||||
// The join is projected, never shipped raw.
|
||||
expect(body.data.tagLinks).toBeUndefined();
|
||||
const findCall = vi.mocked(prisma.moodEntry.findFirst).mock.calls[0][0] as {
|
||||
include?: Record<string, unknown>;
|
||||
};
|
||||
expect(Object.keys(findCall.include ?? {}).sort()).toEqual([
|
||||
"context",
|
||||
"tagLinks",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `mood` enum + bad `moodLoggedAt`.
|
||||
|
||||
@@ -15,10 +15,11 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { MOOD_ENTRY_WIRE_INCLUDE, toMoodEntryWire } from "@/lib/mood/list-read";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { deriveA1, shapeLevelA } from "@/lib/mood/level-a";
|
||||
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
@@ -50,9 +51,9 @@ export const GET = apiHandler(
|
||||
// (not `findUnique`) because `deletedAt` is not part of a unique index.
|
||||
const entry = await prisma.moodEntry.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
// v1.38 — the day context rides with the entry rather than behind a
|
||||
// second request: the edit surface needs both or neither.
|
||||
include: { context: true },
|
||||
// The same joins and the same projection as the list read, so an
|
||||
// entry opened on its own carries `tagKeys` and `ratedFactors` too.
|
||||
include: MOOD_ENTRY_WIRE_INCLUDE,
|
||||
});
|
||||
|
||||
if (!entry || entry.userId !== user.id) {
|
||||
@@ -64,14 +65,7 @@ export const GET = apiHandler(
|
||||
meta: { moodEntryId: id },
|
||||
});
|
||||
|
||||
const { context, ...row } = entry;
|
||||
return apiSuccess({
|
||||
// v1.37 — level-A values under the keys the write path takes.
|
||||
...shapeLevelA(shapeMoodNote(row)),
|
||||
tags: parseTags(row.tags),
|
||||
// v1.38 — lists decoded, note decrypted, ciphertext dropped.
|
||||
context: context ? contextForWire(context) : null,
|
||||
});
|
||||
return apiSuccess(toMoodEntryWire(entry));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
trendSlope,
|
||||
detectAnomalies,
|
||||
summarize,
|
||||
wireSummary,
|
||||
type DataPoint,
|
||||
} from "../trends";
|
||||
|
||||
@@ -166,3 +167,19 @@ describe("summarize", () => {
|
||||
expect(summary.latest).toBe(data[N - 1].value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wireSummary", () => {
|
||||
it("drops the in-process anomaly count and keeps every published statistic", () => {
|
||||
const data = Array.from({ length: 10 }, (_, i) => ({
|
||||
date: new Date(Date.UTC(2026, 0, i + 1)),
|
||||
value: i === 9 ? 400 : 70 + i,
|
||||
}));
|
||||
const full = summarize(data);
|
||||
expect(full).toHaveProperty("anomalyCount");
|
||||
|
||||
const wire = wireSummary(full);
|
||||
expect(wire).not.toHaveProperty("anomalyCount");
|
||||
const { anomalyCount: _dropped, ...rest } = full;
|
||||
expect(wire).toEqual(rest);
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ import {
|
||||
isHourlyMeanTypeSql,
|
||||
windowMeanSql,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { wireSummary, type DataSummary } from "@/lib/analytics/trends";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
@@ -491,7 +491,7 @@ async function withSleepNightTotals(
|
||||
// dashboard tile reads slope30); recompute them off the night series is
|
||||
// out of scope here — the per-night `summarize()` already fills slope7 /
|
||||
// slope30 from the night DataPoints, so use them directly.
|
||||
slice.summaries.SLEEP_DURATION = summary;
|
||||
slice.summaries.SLEEP_DURATION = wireSummary(summary);
|
||||
// Additive, observational: the latest night's main session saw two
|
||||
// writer buckets with clearly different asleep totals. Round to whole
|
||||
// minutes, matching the `/api/sleep/night` serialisation.
|
||||
|
||||
@@ -156,6 +156,20 @@ export interface DataSummary {
|
||||
avg30LastYear?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A summary as `/api/analytics` publishes it: without `anomalyCount`.
|
||||
*
|
||||
* The SQL summaries path cannot compute the figure, so the route's published
|
||||
* summary has not carried it since v1.37.19. Two of its summaries are still
|
||||
* built by `summarize()` (the per-night sleep series and the glucose
|
||||
* meal-context split), and both put it back on the wire until they were run
|
||||
* through this.
|
||||
*/
|
||||
export function wireSummary(summary: DataSummary): DataSummary {
|
||||
const { anomalyCount: _inProcessOnly, ...wire } = summary;
|
||||
return wire;
|
||||
}
|
||||
|
||||
export function summarize(data: DataPoint[]): DataSummary {
|
||||
if (data.length === 0) {
|
||||
// Empty series — return null for stats so callers can render an explicit
|
||||
|
||||
@@ -12,8 +12,25 @@
|
||||
* no rollup, no sync, no FHIR, no insights. Charts read entries LIVE.
|
||||
*/
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
|
||||
/**
|
||||
* The joins behind a published custom-metric row: its newest live entry and
|
||||
* its live entry count. Every read that answers with the row — the
|
||||
* catalogue, the single metric, the edit echo — includes these, so `latest`
|
||||
* and `entryCount` cannot be present on one and missing on another.
|
||||
*/
|
||||
export const CUSTOM_METRIC_LATEST_INCLUDE = {
|
||||
entries: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 1,
|
||||
select: { value: true, unit: true, measuredAt: true },
|
||||
},
|
||||
_count: { select: { entries: { where: { deletedAt: null } } } },
|
||||
} satisfies Prisma.CustomMetricInclude;
|
||||
|
||||
/** A custom-metric catalog row as the API serialises it. */
|
||||
export interface CustomMetricRow {
|
||||
id: string;
|
||||
|
||||
+63
-41
@@ -5,6 +5,7 @@
|
||||
* client `queryFn` gets from the wire. Ciphertext never leaves this module:
|
||||
* the note is decrypted onto `note` and the context rides `contextForWire`.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { shapeLevelA } from "@/lib/mood/level-a";
|
||||
@@ -30,6 +31,66 @@ function parseTags(tags: string | null): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a read of one entry joins: the structured-tag links with their rating
|
||||
* and kind, and the day context.
|
||||
*
|
||||
* v1.8.5 — the link keys pre-populate the taxonomy picker; v1.12.0 — the
|
||||
* per-link `rating` + tag `kind` split binary tags from rated factors on
|
||||
* hydrate; v1.38 — the day context, sparse by design: most rows carry none
|
||||
* and the join costs nothing on those.
|
||||
*/
|
||||
export const MOOD_ENTRY_WIRE_INCLUDE = {
|
||||
tagLinks: {
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true, kind: true } },
|
||||
},
|
||||
},
|
||||
context: true,
|
||||
} satisfies Prisma.MoodEntryInclude;
|
||||
|
||||
type MoodEntryWithWireJoins = Prisma.MoodEntryGetPayload<{
|
||||
include: typeof MOOD_ENTRY_WIRE_INCLUDE;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The one wire shape of a mood entry, for the list and the single-entry
|
||||
* read alike, so an entry opened on its own carries the same fields as the
|
||||
* same entry in the list.
|
||||
*/
|
||||
export function toMoodEntryWire({
|
||||
tagLinks,
|
||||
context,
|
||||
...e
|
||||
}: MoodEntryWithWireJoins) {
|
||||
return {
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
// v1.37 — the five level-A values under the keys the create path takes,
|
||||
// so the edit form reads back what it wrote instead of mapping column
|
||||
// names to wire names by hand. The column spelling is stripped, not
|
||||
// joined: one number under two names is a choice nobody made.
|
||||
...shapeLevelA(shapeMoodNote(e)),
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of binary structured-tag keys attached to the
|
||||
// entry (rated factors are surfaced separately below).
|
||||
tagKeys: tagLinks
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => link.moodTag.key),
|
||||
// v1.12.0 — rated factors with their per-entry score, so the edit
|
||||
// form re-renders the sliders without a refetch.
|
||||
ratedFactors: tagLinks
|
||||
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating as number,
|
||||
})),
|
||||
// v1.38 — the day context, or null. The ciphertext never leaves the
|
||||
// server: `contextForWire` reads the note and drops the column.
|
||||
context: context ? contextForWire(context) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listMoodEntriesPage(
|
||||
userId: string,
|
||||
params: MoodEntryListParams,
|
||||
@@ -59,51 +120,12 @@ export async function listMoodEntriesPage(
|
||||
orderBy: { [sortBy]: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
// v1.8.5 — include the structured-tag link keys so the edit form
|
||||
// can pre-populate the taxonomy picker.
|
||||
// v1.12.0 — also carry the per-link `rating` + the tag `kind` so
|
||||
// the client can split binary tags from rated factors on hydrate.
|
||||
include: {
|
||||
tagLinks: {
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true, kind: true } },
|
||||
},
|
||||
},
|
||||
// v1.38 — the day context, so the edit dialog can pre-populate the
|
||||
// sections without a second round trip per row. Sparse by design: most
|
||||
// rows carry none and the join costs nothing on those.
|
||||
context: true,
|
||||
},
|
||||
include: MOOD_ENTRY_WIRE_INCLUDE,
|
||||
}),
|
||||
prisma.moodEntry.count({ where }),
|
||||
]);
|
||||
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
// v1.37 — the five level-A values under the keys the create path takes,
|
||||
// so the edit form reads back what it wrote instead of mapping column
|
||||
// names to wire names by hand. The column spelling is stripped, not
|
||||
// joined: one number under two names is a choice nobody made.
|
||||
...shapeLevelA(shapeMoodNote(e)),
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of binary structured-tag keys attached to the
|
||||
// entry (rated factors are surfaced separately below).
|
||||
tagKeys: tagLinks
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => link.moodTag.key),
|
||||
// v1.12.0 — rated factors with their per-entry score, so the edit
|
||||
// form re-renders the sliders without a refetch.
|
||||
ratedFactors: tagLinks
|
||||
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating as number,
|
||||
})),
|
||||
// v1.38 — the day context, or null. The ciphertext never leaves the
|
||||
// server: `contextForWire` reads the note and drops the column.
|
||||
context: context ? contextForWire(context) : null,
|
||||
}));
|
||||
const entriesWithParsedTags = entries.map(toMoodEntryWire);
|
||||
|
||||
return {
|
||||
entries: entriesWithParsedTags,
|
||||
|
||||
@@ -32,45 +32,6 @@ import { isoDateTime } from "@/lib/validations/datetime";
|
||||
/** The retrospective periods the narrative route accepts, read off the engine. */
|
||||
const NARRATIVE_PERIOD_VALUES = Object.keys(PERIOD_DAYS);
|
||||
|
||||
export const insightsComprehensiveResponse = z
|
||||
.object({
|
||||
summary: z.string(),
|
||||
recommendations: z.array(z.record(z.string(), z.unknown())),
|
||||
citations: z.array(z.record(z.string(), z.unknown())),
|
||||
warnings: z.array(z.record(z.string(), z.unknown())),
|
||||
dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
storyboardAnnotations: z
|
||||
.array(z.record(z.string(), z.unknown()))
|
||||
.optional(),
|
||||
metricSource: z.record(z.string(), z.unknown()).optional(),
|
||||
revalidating: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.",
|
||||
),
|
||||
hasProvider: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Whether a configured AI provider exists for the record: provider presence and nothing else. False never means the operator switched AI off or consent is missing; `ai` says that.",
|
||||
),
|
||||
ai: z
|
||||
.object({
|
||||
briefing: aiCapabilityState,
|
||||
statusText: aiCapabilityState,
|
||||
})
|
||||
.describe(
|
||||
"Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsComprehensiveResponse",
|
||||
description:
|
||||
"The Insights overview's computed read. No provider is called on this path; every figure is computed from the record.",
|
||||
});
|
||||
|
||||
// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is
|
||||
// derived from the same registry the route validates against, so the
|
||||
// spec, the route, and the cache scope cannot drift. The seven
|
||||
@@ -1999,6 +1960,166 @@ export const dataSummary = z
|
||||
"Per-measurement-type summary. Every statistic is null on an empty series rather than 0, so a client never charts a zero it was never given. `anomalyCount` and `slope90` were computed here historically and left this wire in v1.37.19 — they are not fields of this shape.",
|
||||
});
|
||||
|
||||
/**
|
||||
* A summary the overview computes in-process. The same statistics as the
|
||||
* analytics summary, plus the count of readings the outlier rule flags, which
|
||||
* only the in-process path can produce. Declared here rather than derived from
|
||||
* either `DataSummary` copy so its trend component cannot collide with theirs.
|
||||
*/
|
||||
const comprehensiveTrend = z.object({
|
||||
slope: z.number().describe("Least-squares slope in units per day."),
|
||||
direction: z.enum(["up", "down", "stable"]),
|
||||
confidence: z.number().describe("R² of the fit, 0..1."),
|
||||
});
|
||||
|
||||
const comprehensiveSummary = z
|
||||
.object({
|
||||
count: z.number().int(),
|
||||
latest: z.number().nullable(),
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
mean: z.number().nullable(),
|
||||
median: z.number().nullable(),
|
||||
avg7: z.number().nullable(),
|
||||
avg30: z.number().nullable(),
|
||||
slope7: comprehensiveTrend.nullable(),
|
||||
slope30: comprehensiveTrend.nullable(),
|
||||
anomalyCount: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe("Readings in the series the outlier rule flags."),
|
||||
avg30LastMonth: z.number().nullable().optional(),
|
||||
avg30LastYear: z.number().nullable().optional(),
|
||||
})
|
||||
.meta({ id: "ComprehensiveMetricSummary" });
|
||||
|
||||
const correlationStat = z
|
||||
.object({
|
||||
r: z.number(),
|
||||
strength: z
|
||||
.enum(["stark", "moderat", "schwach", "keine"])
|
||||
.describe(
|
||||
"Strength bucket. The tokens are GERMAN on the wire; treat them as opaque identifiers and localise client-side.",
|
||||
),
|
||||
n: z.number().int(),
|
||||
})
|
||||
.meta({
|
||||
id: "CorrelationStat",
|
||||
description:
|
||||
"A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not significant.",
|
||||
});
|
||||
|
||||
const classification = z.object({
|
||||
category: z.string(),
|
||||
color: z.string().describe("A CSS colour token for the web client."),
|
||||
severity: z.enum(["normal", "elevated", "warning", "danger"]),
|
||||
});
|
||||
|
||||
export const insightsComprehensiveResponse = z
|
||||
.object({
|
||||
summaries: z
|
||||
.record(z.string(), comprehensiveSummary)
|
||||
.describe(
|
||||
"Keyed by `MeasurementType`. Only types with readings in the last 90 days are present.",
|
||||
),
|
||||
bmi: z.number().nullable(),
|
||||
bmiClassification: classification.nullable(),
|
||||
bpClassification: classification.nullable(),
|
||||
bpPctInTarget: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Share of blood-pressure readings inside the target band, 0-100.",
|
||||
),
|
||||
bpTargets: z
|
||||
.object({
|
||||
sysLow: z.number(),
|
||||
sysHigh: z.number(),
|
||||
diaLow: z.number(),
|
||||
diaHigh: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.describe("Age-banded targets; null without a date of birth."),
|
||||
weightBpCorrelation: correlationStat.nullable(),
|
||||
scatterData: z
|
||||
.array(z.object({ weight: z.number(), sysBP: z.number() }))
|
||||
.describe("Weight against systolic pressure, paired by day."),
|
||||
bpMedicationCorrelation: correlationStat
|
||||
.extend({ medicationCount: z.number().int() })
|
||||
.nullable(),
|
||||
bpMedicationScatterData: z.array(
|
||||
z.object({ continuityPct: z.number(), sysBP: z.number() }),
|
||||
),
|
||||
moodSummary: comprehensiveSummary.nullable(),
|
||||
moodBpCorrelation: correlationStat.nullable(),
|
||||
moodBpScatterData: z.array(
|
||||
z.object({ mood: z.number(), sysBP: z.number() }),
|
||||
),
|
||||
moodWeightCorrelation: correlationStat.nullable(),
|
||||
moodWeightScatterData: z.array(
|
||||
z.object({ mood: z.number(), weight: z.number() }),
|
||||
),
|
||||
moodPulseCorrelation: correlationStat.nullable(),
|
||||
moodPulseScatterData: z.array(
|
||||
z.object({ mood: z.number(), pulse: z.number() }),
|
||||
),
|
||||
medications: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
dose: z.string(),
|
||||
category: z.string(),
|
||||
categoryLabel: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The label of a custom category; null for a built-in one.",
|
||||
),
|
||||
compliance7: z.number().describe("Percent, 0-100."),
|
||||
compliance30: z.number().describe("Percent, 0-100."),
|
||||
streak: z.number().int(),
|
||||
taken7: z.number().int(),
|
||||
skipped7: z.number().int(),
|
||||
missed7: z.number().int(),
|
||||
}),
|
||||
)
|
||||
.describe("Cadence-aware compliance per active medication."),
|
||||
alerts: z.array(
|
||||
z.object({
|
||||
level: z.enum(["success", "info", "warning", "danger"]),
|
||||
title: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
),
|
||||
hasProvider: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether a configured AI provider exists for the record: provider presence and nothing else. False never means the operator switched AI off or consent is missing; `ai` says that.",
|
||||
),
|
||||
dataSpanDays: z.number().int().nonnegative(),
|
||||
totalMeasurements: z.number().int().nonnegative(),
|
||||
revalidating: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.",
|
||||
),
|
||||
ai: z
|
||||
.object({
|
||||
briefing: aiCapabilityState,
|
||||
statusText: aiCapabilityState,
|
||||
})
|
||||
.describe(
|
||||
"Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsComprehensiveResponse",
|
||||
description:
|
||||
"The Insights overview's computed read. No provider is called on this path; every figure is computed from the record. The model-written briefing is not part of it; it is read from `GET /api/insights/generate`.",
|
||||
});
|
||||
|
||||
const lastSeenSlot = z
|
||||
.object({
|
||||
lastSeenAt: isoDateTime(),
|
||||
|
||||
@@ -290,6 +290,13 @@ export const measurementResource = z
|
||||
.optional()
|
||||
.describe("External-system dedup key; null for manual entries."),
|
||||
externalSourceVersion: z.string().nullable().optional(),
|
||||
aggregationProvenance: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values leniently.",
|
||||
),
|
||||
glucoseContext: z
|
||||
.string()
|
||||
.nullable()
|
||||
@@ -450,7 +457,11 @@ const sleepSessionResource = z.object({
|
||||
.describe(
|
||||
"True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).",
|
||||
),
|
||||
stages: z.record(sleepStageEnum, z.number().int().nonnegative()),
|
||||
stages: z
|
||||
.partialRecord(sleepStageEnum, z.number().int().nonnegative())
|
||||
.describe(
|
||||
"Minutes per stage. Only the stages the night recorded are present; an absent stage is not zero minutes, it was not measured.",
|
||||
),
|
||||
segments: z.array(sleepSegmentResource),
|
||||
sourceDiscrepancy: z
|
||||
.object({
|
||||
@@ -757,8 +768,10 @@ const seriesBatchRowSchema = z.object({
|
||||
const seriesBatchResponse = z
|
||||
.object({
|
||||
series: z
|
||||
.record(measurementTypeEnum, z.array(seriesBatchRowSchema))
|
||||
.describe("Per-type daily series, keyed by MeasurementType."),
|
||||
.partialRecord(measurementTypeEnum, z.array(seriesBatchRowSchema))
|
||||
.describe(
|
||||
"Per-type daily series, keyed by MeasurementType. Only the requested types are present.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementsSeriesBatchResponse",
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
MEDICATION_CATEGORY_VALUES,
|
||||
MEDICATION_CONTAINER_TYPE_VALUES,
|
||||
MEDICATION_TREATMENT_CLASS_VALUES,
|
||||
doseWindowEntrySchema,
|
||||
injectionSiteEnum,
|
||||
} from "@/lib/validations/medication";
|
||||
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
|
||||
import {
|
||||
@@ -223,6 +225,13 @@ export const medicationScheduleResource = z
|
||||
.describe(
|
||||
'v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.',
|
||||
),
|
||||
doseWindows: z
|
||||
.array(doseWindowEntrySchema)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-dose on-time windows as stored. Null when every dose time keeps the default derivation.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationSchedule",
|
||||
@@ -233,9 +242,54 @@ export const medicationScheduleResource = z
|
||||
export const medicationResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
userId: z.string().optional().describe("The record the row belongs to."),
|
||||
name: z.string(),
|
||||
dose: z.string(),
|
||||
treatmentClass: medicationTreatmentClassEnum,
|
||||
deliveryForm: z
|
||||
.enum(["ORAL", "INJECTION", "OTHER"])
|
||||
.optional()
|
||||
.describe(
|
||||
"How the medication is taken. A one-time injection is `oneShot` with `INJECTION`.",
|
||||
),
|
||||
trackInjectionSites: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Whether intakes record an injection site. Only offered for `INJECTION`.",
|
||||
),
|
||||
allowedInjectionSites: z
|
||||
.array(injectionSiteEnum)
|
||||
.optional()
|
||||
.describe("The sites the rotation offers; empty means every site."),
|
||||
externalSource: z
|
||||
.literal("APPLE_HEALTH")
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ HealthKit Medications sync; NULL is a native HealthLog medication.",
|
||||
),
|
||||
externalId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one.",
|
||||
),
|
||||
lowStockNotifiedAt: isoDateTime()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"When the low-stock alert last fired for this medication; null when it has not since the stock was last topped up. Server-written.",
|
||||
),
|
||||
lowStockNotifiedThresholdDays: z
|
||||
.number()
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"The runway threshold, in days, that alert fired at. Server-written.",
|
||||
),
|
||||
dosesPerUnit: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -371,6 +425,12 @@ export const medicationListEntry = medicationResource
|
||||
// even though the shared base leaves them optional for the write paths.
|
||||
nextDueAt: isoDateTime().nullable(),
|
||||
nextDueOverdue: z.boolean(),
|
||||
nextDueScheduleId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The schedule `nextDueAt` belongs to; null when nothing is due.",
|
||||
),
|
||||
remindersActive: z
|
||||
.boolean()
|
||||
.describe(
|
||||
|
||||
@@ -1192,6 +1192,12 @@ const moodAggregatesResponse = z
|
||||
.array(z.object({ x: z.number(), y: z.number() }))
|
||||
.describe("Mood (x) versus metric (y), paired by day."),
|
||||
n: z.number().int().describe("Paired-day count."),
|
||||
suppressed: z
|
||||
.enum(["insufficientPairs", "notSignificant"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Why `result` is null: too few paired days, or a pairing that did not reach significance. Absent when `result` is set.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
|
||||
@@ -383,6 +383,18 @@ const notificationPrefsResolved = z
|
||||
nudgeFrequency: z
|
||||
.enum(["weekly", "biweekly"])
|
||||
.describe("Nudge frequency cap: one per 7 or per 14 days."),
|
||||
nudgeAiComposed: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Opt-in for model-written nudge copy. Off by default; the deterministic template is always the fallback.",
|
||||
),
|
||||
}),
|
||||
measurementReminder: z.object({
|
||||
clientManaged: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the iOS app owns local check-up reminders; the server-side measurement-reminder APNs send is then suppressed. Default false.",
|
||||
),
|
||||
}),
|
||||
content: z.object({
|
||||
detail: z
|
||||
|
||||
@@ -128,7 +128,10 @@ const profileSettings = z
|
||||
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"]).nullable(),
|
||||
timezone: z.string(),
|
||||
unitPreference: z.enum(["metric", "imperial"]),
|
||||
unitPreference: z
|
||||
.enum(["metric", "imperial"])
|
||||
.nullable()
|
||||
.describe("Null when the record never chose one: metric applies."),
|
||||
timeFormat: z.enum(["AUTO", "H12", "H24"]),
|
||||
dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user