diff --git a/src/app/api/insights/layout/__tests__/route.test.ts b/src/app/api/insights/layout/__tests__/route.test.ts index 5fc162a6f..ac014f293 100644 --- a/src/app/api/insights/layout/__tests__/route.test.ts +++ b/src/app/api/insights/layout/__tests__/route.test.ts @@ -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", () => { diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 20b0fb663..699e353c6 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -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), diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 418765dcf..13a6f65b4 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -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/`) 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 = { + 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(INSIGHTS_TILE_IDS); - const filtered = candidate.tiles.filter((t) => knownIds.has(t.id)); + const seen = new Set(); + 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(); 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 })),