mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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
@@ -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));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user