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:
Marc-André Bombeck committed 2026-05-31 22:12:25 +02:00
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", () => {
+13 -6
View File
@@ -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
View File
@@ -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
})),