mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(insights): accept legacy german tile ids as aliases
The tile-layout enum migrates from German to English to match the route slugs. The contract on `GET/PUT /api/insights/layout` stays non-breaking for clients that persisted the old German ids (the iOS layout sync among them): - the Zod enum is built from `ACCEPTED_INSIGHTS_TILE_IDS` (canonical English plus the legacy German aliases), so a PUT carrying old ids validates rather than tripping a 422; - `normalizeInsightsTileId` collapses each legacy id onto its canonical English replacement on both the read path (`resolveInsightsLayout`) and the write path (`serializeInsightsLayout`), so a pre-rename layout surfaces English ids on GET without a forced re-PUT and a legacy PUT persists English ids; - a layout carrying both a legacy and a canonical id for one tile dedupes to a single canonical entry. The legacy ids are accepted-but-deprecated. Tests cover the read-side normalisation and the write-side 200 (no 422) + canonical persistence.
This commit is contained in:
1 parent
c4ccd7e297
commit
607dd9e5df
3 files changed
+203
-39
No files matched your search
@@ -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),
|
||||
|
||||
+114
-28
@@ -21,33 +21,85 @@
|
||||
* `DEFAULT_INSIGHTS_LAYOUT.tiles[].order` drives the visual order.
|
||||
* Exported so the API Zod `tileIdEnum` can be built from a single
|
||||
* source of truth.
|
||||
*
|
||||
* v1.8.0 — the canonical ids are English, matching the routed slugs
|
||||
* (`/insights/<slug>`) and the naming-convention ADR
|
||||
* (`docs/adr/0001-insights-naming-convention.md`). The ids were German
|
||||
* through v1.7.x and the iOS layout-sync contract
|
||||
* (`GET/PUT /api/insights/layout`) speaks them, so the layout endpoint
|
||||
* keeps ACCEPTING the legacy German ids and NORMALISES them to the
|
||||
* canonical English ones on both read and write — see
|
||||
* `LEGACY_INSIGHTS_TILE_ID_ALIASES` + `normalizeInsightsTileId` below.
|
||||
* No client breaks; iOS migrates to the English ids at its own pace.
|
||||
*/
|
||||
export const INSIGHTS_TILE_IDS = [
|
||||
"overview",
|
||||
// ── vitals ──
|
||||
"blutdruck",
|
||||
"puls",
|
||||
"sauerstoff",
|
||||
"koerpertemperatur",
|
||||
"blood-pressure",
|
||||
"pulse",
|
||||
"oxygen",
|
||||
"body-temperature",
|
||||
// ── body composition ──
|
||||
"gewicht",
|
||||
"weight",
|
||||
"bmi",
|
||||
// ── activity ──
|
||||
"aktive-energie",
|
||||
"active-energy",
|
||||
"workouts",
|
||||
// ── sleep ──
|
||||
"schlaf",
|
||||
"sleep",
|
||||
// ── cardiovascular ──
|
||||
"ruhepuls",
|
||||
"resting-pulse",
|
||||
"hrv",
|
||||
// ── mood ──
|
||||
"stimmung",
|
||||
"mood",
|
||||
// ── events ──
|
||||
"medikamente",
|
||||
"medications",
|
||||
] as const;
|
||||
|
||||
export type InsightsTileId = (typeof INSIGHTS_TILE_IDS)[number];
|
||||
|
||||
/**
|
||||
* Legacy (≤ v1.7.x) German tile ids mapped to their canonical English
|
||||
* replacement. The layout endpoint accepts these as input so the iOS
|
||||
* client's stored layouts keep round-tripping after the v1.8.0 rename;
|
||||
* every write path normalises them away before persisting (see
|
||||
* `normalizeInsightsTileId`). `bmi`, `hrv`, `workouts`, and `overview`
|
||||
* were already language-neutral and need no alias.
|
||||
*/
|
||||
export const LEGACY_INSIGHTS_TILE_ID_ALIASES: Record<string, InsightsTileId> = {
|
||||
blutdruck: "blood-pressure",
|
||||
puls: "pulse",
|
||||
sauerstoff: "oxygen",
|
||||
koerpertemperatur: "body-temperature",
|
||||
gewicht: "weight",
|
||||
"aktive-energie": "active-energy",
|
||||
schlaf: "sleep",
|
||||
ruhepuls: "resting-pulse",
|
||||
stimmung: "mood",
|
||||
medikamente: "medications",
|
||||
};
|
||||
|
||||
/**
|
||||
* Every id the layout endpoint accepts on input — canonical English
|
||||
* plus the legacy German aliases. The Zod `tileIdEnum` is built from
|
||||
* this union so a PUT carrying old ids passes validation rather than
|
||||
* tripping a 422; `normalizeInsightsTileId` collapses the union back to
|
||||
* the canonical id before anything persists.
|
||||
*/
|
||||
export const ACCEPTED_INSIGHTS_TILE_IDS = [
|
||||
...INSIGHTS_TILE_IDS,
|
||||
...Object.keys(LEGACY_INSIGHTS_TILE_ID_ALIASES),
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Collapse a legacy German tile id onto its canonical English id.
|
||||
* Canonical ids (and unknown ids) pass through unchanged — the
|
||||
* resolver's known-id filter drops anything truly unrecognised.
|
||||
*/
|
||||
export function normalizeInsightsTileId(id: string): string {
|
||||
return LEGACY_INSIGHTS_TILE_ID_ALIASES[id] ?? id;
|
||||
}
|
||||
|
||||
export interface InsightsTileConfig {
|
||||
id: InsightsTileId;
|
||||
visible: boolean;
|
||||
@@ -75,19 +127,19 @@ export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = {
|
||||
version: INSIGHTS_LAYOUT_VERSION,
|
||||
tiles: [
|
||||
{ id: "overview", visible: true, order: 0 },
|
||||
{ id: "blutdruck", visible: true, order: 1 },
|
||||
{ id: "puls", visible: true, order: 2 },
|
||||
{ id: "sauerstoff", visible: false, order: 3 },
|
||||
{ id: "koerpertemperatur", visible: false, order: 4 },
|
||||
{ id: "gewicht", visible: true, order: 5 },
|
||||
{ id: "blood-pressure", visible: true, order: 1 },
|
||||
{ id: "pulse", visible: true, order: 2 },
|
||||
{ id: "oxygen", visible: false, order: 3 },
|
||||
{ id: "body-temperature", visible: false, order: 4 },
|
||||
{ id: "weight", visible: true, order: 5 },
|
||||
{ id: "bmi", visible: true, order: 6 },
|
||||
{ id: "aktive-energie", visible: false, order: 7 },
|
||||
{ id: "active-energy", visible: false, order: 7 },
|
||||
{ id: "workouts", visible: true, order: 8 },
|
||||
{ id: "schlaf", visible: false, order: 9 },
|
||||
{ id: "ruhepuls", visible: false, order: 10 },
|
||||
{ id: "sleep", visible: false, order: 9 },
|
||||
{ id: "resting-pulse", visible: false, order: 10 },
|
||||
{ id: "hrv", visible: false, order: 11 },
|
||||
{ id: "stimmung", visible: true, order: 12 },
|
||||
{ id: "medikamente", visible: true, order: 13 },
|
||||
{ id: "mood", visible: true, order: 12 },
|
||||
{ id: "medications", visible: true, order: 13 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -101,13 +153,22 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
|
||||
return DEFAULT_INSIGHTS_LAYOUT;
|
||||
}
|
||||
|
||||
// Drop tile ids the current build does not know about so a future
|
||||
// retirement (mirroring the dashboard's v1.4.28 `glp1` drop) doesn't
|
||||
// wedge the PUT round-trip on the next save. Filtering on read keeps
|
||||
// the GET shape current-build-safe and lets the Settings UI re-PUT
|
||||
// a clean array.
|
||||
// v1.8.0 — normalise legacy German tile ids onto their canonical
|
||||
// English replacement BEFORE the known-id filter so a layout persisted
|
||||
// by a ≤ v1.7.x client (or the iOS app still speaking the old ids)
|
||||
// survives the read instead of being silently dropped as "unknown".
|
||||
// Dedupe afterwards: a layout carrying both the legacy and the
|
||||
// canonical id (e.g. a partial migration) keeps the first occurrence
|
||||
// so the round-trip stays idempotent.
|
||||
const knownIds = new Set<string>(INSIGHTS_TILE_IDS);
|
||||
const filtered = candidate.tiles.filter((t) => knownIds.has(t.id));
|
||||
const seen = new Set<string>();
|
||||
const filtered: InsightsTileConfig[] = candidate.tiles
|
||||
.map((t) => ({ ...t, id: normalizeInsightsTileId(t.id) }))
|
||||
.filter((t): t is InsightsTileConfig => {
|
||||
if (!knownIds.has(t.id) || seen.has(t.id)) return false;
|
||||
seen.add(t.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Merge with defaults so new tiles introduced in later versions show
|
||||
// up automatically (invisible by default, the user opts in).
|
||||
@@ -128,16 +189,41 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Input shape for {@link serializeInsightsLayout}. Looser than
|
||||
* {@link InsightsLayout}: the tile `id` is a bare `string` so the PUT
|
||||
* handler can hand a Zod-parsed body that still carries legacy German
|
||||
* aliases — `serializeInsightsLayout` normalises them onto canonical
|
||||
* {@link InsightsTileId} values on the way through.
|
||||
*/
|
||||
export interface InsightsLayoutInput {
|
||||
version: number;
|
||||
tiles: Array<{ id: string; visible: boolean; order: number }>;
|
||||
}
|
||||
|
||||
export function serializeInsightsLayout(
|
||||
layout: InsightsLayout,
|
||||
layout: InsightsLayoutInput,
|
||||
): InsightsLayout {
|
||||
// v1.8.0 — normalise legacy German tile ids onto canonical English
|
||||
// before persisting so the stored blob is always canonical even when
|
||||
// the PUT body carried the old ids (the Zod enum accepts both via
|
||||
// `ACCEPTED_INSIGHTS_TILE_IDS`). Dedupe so a body sending both the
|
||||
// legacy and canonical id for one tile collapses to a single entry,
|
||||
// keeping the dense 0-based order contiguous.
|
||||
const seen = new Set<string>();
|
||||
return {
|
||||
version: INSIGHTS_LAYOUT_VERSION,
|
||||
tiles: layout.tiles
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((t) => ({ ...t, id: normalizeInsightsTileId(t.id) }))
|
||||
.filter((t) => {
|
||||
if (seen.has(t.id)) return false;
|
||||
seen.add(t.id);
|
||||
return true;
|
||||
})
|
||||
.map((t, i) => ({
|
||||
id: t.id,
|
||||
id: t.id as InsightsTileId,
|
||||
visible: t.visible,
|
||||
order: i, // normalize to 0-based dense order
|
||||
})),
|
||||
|
||||
Reference in new issue
Block a user