mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge v1.8.0 Insights release into main
# Conflicts: # CHANGELOG.md # docs/api/openapi.yaml # package.json
This commit is contained in:
commit
869f13612a
115 files changed
+5736
-2737
No files matched your search
@@ -4,7 +4,7 @@
|
||||
* Returns a chronological merge of every GLP-1-relevant event the user
|
||||
* has logged: dose changes, injections (with site if recorded),
|
||||
* inventory events, and side-effect-tagged mood entries from the
|
||||
* trailing 90 days. Used by /insights/medikamente's TherapyTimeline
|
||||
* trailing 90 days. Used by /insights/medications' TherapyTimeline
|
||||
* component. Web-only users without a GLP-1 medication get
|
||||
* `hasGlp1: false` so the component hides cleanly.
|
||||
*/
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("GET /api/insights/layout", () => {
|
||||
version: 1,
|
||||
tiles: [
|
||||
{ id: "overview", visible: false, order: 0 },
|
||||
{ id: "blutdruck", visible: true, order: 1 },
|
||||
{ id: "blood-pressure", visible: true, order: 1 },
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
@@ -141,12 +141,42 @@ describe("GET /api/insights/layout", () => {
|
||||
order: 0,
|
||||
});
|
||||
expect(body.data.tiles[1]).toEqual({
|
||||
id: "blutdruck",
|
||||
id: "blood-pressure",
|
||||
visible: true,
|
||||
order: 1,
|
||||
});
|
||||
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
|
||||
});
|
||||
|
||||
it("normalises a legacy German tile id stored by a ≤ v1.7.x client to its canonical English id on read", async () => {
|
||||
// A layout persisted before the v1.8.0 rename (or by an iOS client
|
||||
// still speaking the German ids). The resolver must surface the
|
||||
// canonical English id so the GET contract is current-build clean
|
||||
// without forcing a re-PUT.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: {
|
||||
version: 1,
|
||||
tiles: [
|
||||
{ id: "overview", visible: true, order: 0 },
|
||||
{ id: "blutdruck", visible: true, order: 1 },
|
||||
{ id: "stimmung", visible: false, order: 2 },
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { tiles: Array<{ id: string; visible: boolean; order: number }> };
|
||||
};
|
||||
const ids = body.data.tiles.map((t) => t.id);
|
||||
// Legacy ids collapse to their English replacement…
|
||||
expect(ids).toContain("blood-pressure");
|
||||
expect(ids).toContain("mood");
|
||||
// …and the German originals never leak back out.
|
||||
expect(ids).not.toContain("blutdruck");
|
||||
expect(ids).not.toContain("stimmung");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/insights/layout — happy path", () => {
|
||||
@@ -157,8 +187,8 @@ describe("PUT /api/insights/layout — happy path", () => {
|
||||
version: 1,
|
||||
tiles: [
|
||||
{ id: "overview", visible: true, order: 0 },
|
||||
{ id: "blutdruck", visible: true, order: 1 },
|
||||
{ id: "puls", visible: false, order: 2 },
|
||||
{ id: "blood-pressure", visible: true, order: 1 },
|
||||
{ id: "pulse", visible: false, order: 2 },
|
||||
],
|
||||
};
|
||||
const res = await callPut(makeReq(payload));
|
||||
@@ -168,7 +198,7 @@ describe("PUT /api/insights/layout — happy path", () => {
|
||||
};
|
||||
expect(body.data.tiles[0]?.id).toBe("overview");
|
||||
expect(body.data.tiles[0]?.order).toBe(0);
|
||||
expect(body.data.tiles[2]?.id).toBe("puls");
|
||||
expect(body.data.tiles[2]?.id).toBe("pulse");
|
||||
expect(body.data.tiles[2]?.visible).toBe(false);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
@@ -179,6 +209,47 @@ describe("PUT /api/insights/layout — happy path", () => {
|
||||
expect(updateCall.where.id).toBe("user-1");
|
||||
expect(updateCall.data.insightsLayoutJson).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts a body carrying legacy German tile ids and persists the canonical English ones", async () => {
|
||||
// Non-breaking iOS contract: a client still sending the pre-v1.8.0
|
||||
// German ids must NOT 422. The route validates the legacy ids and
|
||||
// `serializeInsightsLayout` normalises them to English before the
|
||||
// row persists, so the stored blob is always canonical.
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
tiles: [
|
||||
{ id: "overview", visible: true, order: 0 },
|
||||
{ id: "blutdruck", visible: true, order: 1 },
|
||||
{ id: "medikamente", visible: true, order: 2 },
|
||||
],
|
||||
};
|
||||
const res = await callPut(makeReq(payload));
|
||||
// No 422 — the legacy ids pass validation.
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { tiles: Array<{ id: string }> };
|
||||
};
|
||||
const returnedIds = body.data.tiles.map((t) => t.id);
|
||||
expect(returnedIds).toContain("blood-pressure");
|
||||
expect(returnedIds).toContain("medications");
|
||||
expect(returnedIds).not.toContain("blutdruck");
|
||||
expect(returnedIds).not.toContain("medikamente");
|
||||
|
||||
// The persisted blob is canonical too.
|
||||
const updateCall = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
|
||||
data: { insightsLayoutJson: { tiles: Array<{ id: string }> } };
|
||||
};
|
||||
const persistedIds = updateCall.data.insightsLayoutJson.tiles.map(
|
||||
(t) => t.id,
|
||||
);
|
||||
expect(persistedIds).toContain("blood-pressure");
|
||||
expect(persistedIds).toContain("medications");
|
||||
expect(persistedIds).not.toContain("blutdruck");
|
||||
expect(persistedIds).not.toContain("medikamente");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
DEFAULT_INSIGHTS_LAYOUT,
|
||||
INSIGHTS_TILE_IDS,
|
||||
ACCEPTED_INSIGHTS_TILE_IDS,
|
||||
type InsightsLayout,
|
||||
} from "@/lib/insights-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
@@ -38,11 +38,18 @@ import { redactSensitiveFields } from "@/lib/observability/redact-payload";
|
||||
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// Derive the enum from `INSIGHTS_TILE_IDS` so the schema + the default
|
||||
// layout cannot drift — the same root-cause class that produced the
|
||||
// v1.4.16 A5 silent-422 on dashboard widgets when `achievements` was
|
||||
// added to the layout but not to the validation enum.
|
||||
const tileIdEnum = z.enum(INSIGHTS_TILE_IDS);
|
||||
// Derive the enum from `ACCEPTED_INSIGHTS_TILE_IDS` so the schema + the
|
||||
// default layout cannot drift — the same root-cause class that produced
|
||||
// the v1.4.16 A5 silent-422 on dashboard widgets when `achievements`
|
||||
// was added to the layout but not to the validation enum.
|
||||
//
|
||||
// v1.8.0 — the accepted set is canonical English ids PLUS the legacy
|
||||
// German aliases, so an iOS client still PUTting `blutdruck` / `puls` /
|
||||
// … passes validation rather than tripping a 422 on the rename.
|
||||
// `serializeInsightsLayout` normalises any legacy id onto its canonical
|
||||
// English replacement before the row persists, so the stored blob is
|
||||
// always canonical regardless of which id the client sent.
|
||||
const tileIdEnum = z.enum(ACCEPTED_INSIGHTS_TILE_IDS);
|
||||
|
||||
const layoutSchema = z.object({
|
||||
version: z.literal(1),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { updateMeasurementSchema } from "@/lib/validations/measurement";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -172,6 +173,18 @@ export const PUT = apiHandler(
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows this edit
|
||||
// dirties so the next mount / nightly warm pass regenerates against
|
||||
// the new value. An edit can re-type a row, so invalidate both the
|
||||
// old and the new type's scopes. Fire-and-forget: never blocks the
|
||||
// user's edit.
|
||||
invalidateStatusInsightsForTypes(user.id, [
|
||||
existing.type,
|
||||
measurement.type,
|
||||
]).catch((err) => {
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
|
||||
return apiSuccess(measurement);
|
||||
},
|
||||
);
|
||||
@@ -238,6 +251,13 @@ export const DELETE = apiHandler(
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows this deletion
|
||||
// dirties so the next mount / nightly warm pass regenerates against
|
||||
// the reduced history. Fire-and-forget: never blocks the user's delete.
|
||||
invalidateStatusInsightsForTypes(user.id, [existing.type]).catch((err) => {
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -46,6 +46,7 @@ import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
@@ -484,6 +485,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
for (const k of insertedKeys) {
|
||||
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows the ingested
|
||||
// types dirty so the next mount / nightly warm pass regenerates
|
||||
// them against the new data instead of serving stale text.
|
||||
// Fire-and-forget: never a blocker on the user's ingest.
|
||||
invalidateStatusInsightsForTypes(
|
||||
user.id,
|
||||
insertedKeys.map((k) => k.type),
|
||||
).catch((err) => {
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
@@ -152,6 +153,17 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
|
||||
} catch (err) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows the deleted
|
||||
// types dirty so the next mount / nightly warm pass regenerates
|
||||
// against the reconciled history. Fire-and-forget: never blocks the
|
||||
// iOS reconciliation.
|
||||
invalidateStatusInsightsForTypes(
|
||||
user.id,
|
||||
affectedRows.map((row) => row.type),
|
||||
).catch((err) => {
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({ deletedCount: result.count });
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
@@ -664,6 +665,16 @@ async function postMeasurement(request: NextRequest) {
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows the ingested
|
||||
// types dirty so the next mount / nightly warm pass regenerates
|
||||
// them against the new data. Fire-and-forget: never blocks ingest.
|
||||
invalidateStatusInsightsForTypes(
|
||||
user.id,
|
||||
keys.map((k) => k.type),
|
||||
).catch((err) => {
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
@@ -768,5 +779,12 @@ async function postMeasurement(request: NextRequest) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows this type
|
||||
// dirties so the next mount / nightly warm pass regenerates against
|
||||
// the new row. Fire-and-forget: never blocks the user's write.
|
||||
invalidateStatusInsightsForTypes(user.id, [measurement.type]).catch((err) => {
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
|
||||
return apiSuccess(measurement, 201);
|
||||
}
|
||||
Reference in new issue
Block a user