From 93198017d68897ebe7daa9abb0d1d9b4ccd194a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:11:23 +0200 Subject: [PATCH] feat(settings): keep list order and preview opt-ins on the server Add per-user columns for the labs, illness and checkups list presentation and for preview-feature opt-ins, each with a GET plus a preserve-when-absent write under /api/settings. The list hook now reads and writes the server value and carries a browser-stored order up once when the server holds none, so an existing arrangement follows the person to every device instead of resetting. --- docs/api/openapi.yaml | 256 +++++++++++++ .../migration.sql | 17 + prisma/schema.prisma | 13 + .../api/settings/module-list-layout/route.ts | 94 +++++ .../api/settings/preview-features/route.ts | 75 ++++ src/lib/__tests__/module-list-prefs.test.ts | 274 +++++++++---- src/lib/data-wipe/wipe-plan.ts | 3 + src/lib/export/account-settings-backup.ts | 2 + src/lib/export/backup-plan.ts | 2 + src/lib/module-list-prefs.ts | 359 +++++++++++------- src/lib/openapi/routes/settings.ts | 165 ++++++++ src/lib/query-keys/settings.ts | 9 + .../__tests__/module-list-layout.test.ts | 78 ++++ .../__tests__/preview-features.test.ts | 26 ++ src/lib/settings/module-list-layout.ts | 163 ++++++++ src/lib/settings/preview-features.ts | 31 ++ src/lib/validations/backup.ts | 2 + src/lib/validations/settings-prefs.ts | 37 ++ .../integration/settings-prefs-routes.test.ts | 216 +++++++++++ 19 files changed, 1614 insertions(+), 208 deletions(-) create mode 100644 prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql create mode 100644 src/app/api/settings/module-list-layout/route.ts create mode 100644 src/app/api/settings/preview-features/route.ts create mode 100644 src/lib/settings/__tests__/module-list-layout.test.ts create mode 100644 src/lib/settings/__tests__/preview-features.test.ts create mode 100644 src/lib/settings/module-list-layout.ts create mode 100644 src/lib/settings/preview-features.ts create mode 100644 src/lib/validations/settings-prefs.ts create mode 100644 tests/integration/settings-prefs-routes.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b4f13c5b..ed29ff678 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14488,6 +14488,81 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/settings/module-list-layout: + get: + tags: + - Meta + summary: Get the list presentation of labs, illness and checkups + description: Every list resolved, defaults filled in, with `saved` per list. Never writes a row. + responses: + "200": + description: The resolved presentation of every list. + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleListLayoutEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + put: + tags: + - Meta + summary: Change the presentation of one list + description: "Preserve-when-absent: the fields the body leaves out keep their stored value, and every other list stays + as stored. Returns every list resolved." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleListLayoutPutBody" + responses: + "200": + description: The presentation as stored. + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleListLayoutSavedEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + /api/settings/preview-features: + get: + tags: + - Meta + summary: Get the preview-feature opt-ins + description: Every flag, off unless the caller switched it on. + responses: + "200": + description: The opt-ins. + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewFeaturesEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Meta + summary: Switch preview features on or off + description: Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewFeaturesPatchBody" + responses: + "200": + description: The opt-ins as stored. + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewFeaturesSavedEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 /api/settings/privacy-summary: get: tags: @@ -31354,6 +31429,48 @@ components: required: - confirm description: Typed confirmation for erasing the record. No undo. + ModuleListLayoutPutBody: + type: object + properties: + version: + type: number + const: 1 + list: + type: string + enum: + - labs + - illness + - vorsorge + view: + type: string + enum: + - cards + - list + order: + maxItems: 500 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + sortDir: + type: string + enum: + - recentDesc + - recentAsc + - alphaAsc + - alphaDesc + - manual + required: + - version + - list + additionalProperties: false + PreviewFeaturesPatchBody: + type: object + properties: + topics: + type: boolean + additionalProperties: false ConsentPostBody: type: object properties: @@ -38796,6 +38913,8 @@ components: medicationListLayoutJson: {} moodTagLayoutJson: {} documentsLayoutJson: {} + moduleListLayoutJson: {} + previewFeaturesJson: {} reportSelectionJson: {} globalExcludedInjectionSites: type: array @@ -69269,6 +69388,143 @@ components: - deletedRows - models additionalProperties: false + ModuleListLayoutEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ModuleListLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ModuleListLayout: + type: object + properties: + version: + type: number + const: 1 + lists: + type: object + properties: + labs: + $ref: "#/components/schemas/ModuleListPrefs" + illness: + $ref: "#/components/schemas/ModuleListPrefs" + vorsorge: + $ref: "#/components/schemas/ModuleListPrefs" + required: + - labs + - illness + - vorsorge + additionalProperties: false + required: + - version + - lists + additionalProperties: false + description: Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render + a list ignores it. + ModuleListPrefs: + type: object + properties: + view: + type: string + enum: + - cards + - list + description: '`cards` renders the card grid, `list` the compact list. Default "list" for labs, "cards" otherwise.' + order: + type: array + items: + type: string + description: Manual order as an id allow-list. Ids missing from the live data are ignored; items missing from the list + follow in the server order. + sortDir: + type: string + enum: + - recentDesc + - recentAsc + - alphaAsc + - alphaDesc + - manual + description: Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it. + saved: + type: boolean + description: True when the server holds a choice for this list; false means every field is a default. + required: + - view + - order + - sortDir + - saved + additionalProperties: false + ModuleListLayoutSavedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ModuleListLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PreviewFeaturesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/PreviewFeatures" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PreviewFeatures: + type: object + properties: + topics: + type: boolean + description: Topic overview pages above the single-metric Insights pages. Off by default. + required: + - topics + additionalProperties: false + description: Per-user opt-in to preview features, one boolean per flag, every flag off by default. + PreviewFeaturesSavedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/PreviewFeatures" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false PrivacySummaryEnvelope: type: object properties: diff --git a/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql b/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql new file mode 100644 index 000000000..6e9beaa6c --- /dev/null +++ b/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql @@ -0,0 +1,17 @@ +-- Two per-user preference columns for Settings. +-- +-- module_list_layout_json: the card/list view, manual order and labs sort for +-- the labs, illness and checkups lists. These lived in the browser until now, +-- so they did not follow a person to another device. The client copies a value +-- it still holds in the browser up once, when the server has none for that list. +-- +-- preview_features_json: the per-user opt-in to preview features, one boolean +-- per flag, all off by default. +-- +-- Nullable, no default, no backfill: null reads as the defaults. +-- +-- Reversibility: dropping the columns returns every list to its default +-- presentation and switches every preview feature off. + +ALTER TABLE "users" ADD COLUMN "module_list_layout_json" JSONB; +ALTER TABLE "users" ADD COLUMN "preview_features_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1d79d97b1..eb38163bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -364,6 +364,19 @@ model User { // Shape: { "groupOrder": ["", ...], // "placements": { "": ["", ...] } } moodTagLayoutJson Json? @map("mood_tag_layout_json") + // Per-user list presentation for the labs, illness and checkups lists: + // card/list view, manual order, and (labs only) the sort. One blob keyed by + // list, resolved field by field on read (`src/lib/settings/module-list-layout.ts`), + // preserve-when-absent PUT on `/api/settings/module-list-layout`. Null = the + // defaults. Earlier releases kept the same choices in the browser; the client + // copies a stored browser value up once when the server has none for that list. + // Shape: { "version": 1, "labs": { "view": "list", "order": [""], "sortDir": "manual" }, ... } + moduleListLayoutJson Json? @map("module_list_layout_json") + /// Per-user opt-in to preview features, one boolean per flag, all off by + /// default (`src/lib/settings/preview-features.ts`). Read and written by + /// Settings > System through `/api/settings/preview-features`. + /// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will. + previewFeaturesJson Json? @map("preview_features_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in. diff --git a/src/app/api/settings/module-list-layout/route.ts b/src/app/api/settings/module-list-layout/route.ts new file mode 100644 index 000000000..7bd93d3b8 --- /dev/null +++ b/src/app/api/settings/module-list-layout/route.ts @@ -0,0 +1,94 @@ +/** + * GET / PUT the list presentation for the labs, illness and checkups lists. + * + * GET returns every list resolved, defaults filled in, with `saved` per list + * so a client can tell a stored choice from a default. PUT changes one list + * with preserve-when-absent semantics: a body carrying only `view` keeps the + * stored order and sort, and vice versa, the contract `/api/documents/inbound/layout` + * keeps for its fields. The blob lives on its own `User` column + * (`module_list_layout_json`). + * + * Both halves are the caller's own presentation. Inside somebody else's + * record the ids in a saved order simply do not match and the list keeps the + * server order, so no record gate is needed here. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + MODULE_LIST_KEYS, + resolveModuleListLayout, + storedModuleListLayout, +} from "@/lib/settings/module-list-layout"; +import { moduleListLayoutPutSchema } from "@/lib/validations/settings-prefs"; + +export const dynamic = "force-dynamic"; + +async function readStored(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { moduleListLayoutJson: true }, + }); + return row?.moduleListLayoutJson; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const layout = resolveModuleListLayout(await readStored(user.id)); + annotate({ + action: { name: "settings.module-list-layout.read" }, + meta: { + saved: MODULE_LIST_KEYS.filter((list) => layout.lists[list].saved), + }, + }); + return apiSuccess(layout); +}); + +export const PUT = apiHandler(async (request: Request) => { + const { user } = await requireAuth(); + + // 500 ids of up to 64 characters plus the envelope. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 48 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = moduleListLayoutPutSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "settings.module-list-layout.validation-failed" }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { list, view, order, sortDir } = parsed.data; + // Preserve-when-absent: one stored read fills whichever field the body + // left out, and every other list stays as stored. Only fields a person has + // chosen are written, so a field never picked keeps following the default. + const stored = storedModuleListLayout(await readStored(user.id)); + const nextChoices = { + ...stored[list], + ...(view ? { view } : {}), + ...(order ? { order: [...new Set(order)] } : {}), + ...(sortDir ? { sortDir } : {}), + }; + const nextStored = { ...stored, [list]: nextChoices }; + + await prisma.user.update({ + where: { id: user.id }, + data: { moduleListLayoutJson: toJson({ version: 1, ...nextStored }) }, + }); + + const layout = resolveModuleListLayout(nextStored); + annotate({ + action: { name: "settings.module-list-layout.update" }, + meta: { + list, + view: layout.lists[list].view, + sortDir: layout.lists[list].sortDir, + orderLength: layout.lists[list].order.length, + }, + }); + return apiSuccess(layout); +}); diff --git a/src/app/api/settings/preview-features/route.ts b/src/app/api/settings/preview-features/route.ts new file mode 100644 index 000000000..1b48c75fa --- /dev/null +++ b/src/app/api/settings/preview-features/route.ts @@ -0,0 +1,75 @@ +/** + * GET / PATCH the caller's preview-feature opt-ins. + * + * GET answers every flag, off unless the caller switched it on. PATCH sets + * the flags the body names and keeps the rest; unknown flags are refused + * with the multi-issue 422 so a typo cannot silently do nothing. The blob + * lives on its own `User` column (`preview_features_json`). + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + PREVIEW_FEATURE_KEYS, + resolvePreviewFeatures, + type PreviewFeatures, +} from "@/lib/settings/preview-features"; +import { previewFeaturesPatchSchema } from "@/lib/validations/settings-prefs"; + +export const dynamic = "force-dynamic"; + +async function readStored(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { previewFeaturesJson: true }, + }); + return row?.previewFeaturesJson; +} + +function enabledFlags(features: PreviewFeatures): string[] { + return PREVIEW_FEATURE_KEYS.filter((key) => features[key]); +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const features = resolvePreviewFeatures(await readStored(user.id)); + annotate({ + action: { name: "settings.preview-features.read" }, + meta: { enabled: enabledFlags(features) }, + }); + return apiSuccess(features); +}); + +export const PATCH = apiHandler(async (request: Request) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = previewFeaturesPatchSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "settings.preview-features.validation-failed" }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const current = resolvePreviewFeatures(await readStored(user.id)); + const next: PreviewFeatures = { + topics: parsed.data.topics ?? current.topics, + }; + + await prisma.user.update({ + where: { id: user.id }, + data: { previewFeaturesJson: toJson(next) }, + }); + + annotate({ + action: { name: "settings.preview-features.update" }, + meta: { enabled: enabledFlags(next) }, + }); + return apiSuccess(next); +}); diff --git a/src/lib/__tests__/module-list-prefs.test.ts b/src/lib/__tests__/module-list-prefs.test.ts index 1c9eb58fe..a156e6303 100644 --- a/src/lib/__tests__/module-list-prefs.test.ts +++ b/src/lib/__tests__/module-list-prefs.test.ts @@ -1,82 +1,224 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; -import { applyOrder, parseModuleListPrefs } from "@/lib/module-list-prefs"; +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiPut: vi.fn(), +})); +vi.mock("@/components/outcome/outcome-toast", () => ({ + toastWrittenOutcome: vi.fn(), +})); +vi.mock("@/lib/errors/error-toast", () => ({ + showErrorToast: vi.fn(), +})); -describe("module-list-prefs", () => { - describe("parseModuleListPrefs", () => { - it("returns defaults for null / malformed input", () => { - expect(parseModuleListPrefs(null)).toEqual({ - view: "cards", - order: [], - sortDir: "recentDesc", - }); - expect(parseModuleListPrefs("{not json")).toEqual({ - view: "cards", - order: [], - sortDir: "recentDesc", - }); - }); +import { apiPut } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { + applyOrder, + carryOverLegacyModuleListPrefs, + legacyModuleListPatch, + legacyModuleListStorageKey, + parseModuleListPrefs, + resetModuleListCarryOverForTests, + runSetModuleListPrefs, +} from "@/lib/module-list-prefs"; +import { + moduleListDefaults, + resolveModuleListLayout, +} from "@/lib/settings/module-list-layout"; +import { queryKeys } from "@/lib/query-keys"; - it("coerces unknown view / sortDir to defaults but keeps a valid order", () => { - const parsed = parseModuleListPrefs( - JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }), - ); - expect(parsed).toEqual({ - view: "cards", - sortDir: "recentDesc", - order: ["a", "b"], - }); - }); +const store = new Map(); +const fakeStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), +}; - it("round-trips valid values", () => { - const parsed = parseModuleListPrefs( - JSON.stringify({ view: "list", sortDir: "manual", order: ["x"] }), - ); - expect(parsed).toEqual({ - view: "list", - sortDir: "manual", - order: ["x"], - }); - }); +beforeEach(() => { + vi.resetAllMocks(); + store.clear(); + resetModuleListCarryOverForTests(); + vi.stubGlobal("window", { localStorage: fakeStorage }); +}); - it("accepts the alphabetical sort directions (#43)", () => { - for (const sortDir of ["alphaAsc", "alphaDesc"]) { - const parsed = parseModuleListPrefs(JSON.stringify({ sortDir })); - expect(parsed.sortDir).toBe(sortDir); - } - }); +afterEach(() => { + vi.unstubAllGlobals(); +}); - it("applies a per-call default when no blob is stored (Labs list view, #40)", () => { - const labsDefaults = { - view: "list" as const, - order: [], - sortDir: "recentDesc" as const, - }; - expect(parseModuleListPrefs(null, labsDefaults)).toEqual(labsDefaults); - // A stored view still wins over the default. - expect( - parseModuleListPrefs(JSON.stringify({ view: "cards" }), labsDefaults) - .view, - ).toBe("cards"); - }); +describe("parseModuleListPrefs", () => { + const defaults = moduleListDefaults("illness"); + + it("returns defaults for null / malformed input", () => { + expect(parseModuleListPrefs(null, defaults)).toEqual(defaults); + expect(parseModuleListPrefs("{not json", defaults)).toEqual(defaults); }); - describe("applyOrder", () => { - const items = [{ id: "a" }, { id: "b" }, { id: "c" }]; - const idOf = (i: { id: string }) => i.id; + it("coerces unknown view / sortDir to defaults but keeps a valid order", () => { + expect( + parseModuleListPrefs( + JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }), + defaults, + ), + ).toEqual({ view: "cards", sortDir: "recentDesc", order: ["a", "b"] }); + }); - it("returns the input order when no order is persisted", () => { - expect(applyOrder(items, [], idOf)).toEqual(items); + it("applies the labs default (list view, #40) when nothing is stored", () => { + const labs = moduleListDefaults("labs"); + expect(parseModuleListPrefs(null, labs).view).toBe("list"); + expect( + parseModuleListPrefs(JSON.stringify({ view: "cards" }), labs).view, + ).toBe("cards"); + }); +}); + +describe("legacyModuleListPatch", () => { + it("carries only what differs from the default", () => { + expect( + legacyModuleListPatch( + "labs", + JSON.stringify({ view: "list", order: [], sortDir: "manual" }), + ), + ).toEqual({ sortDir: "manual" }); + expect( + legacyModuleListPatch( + "illness", + JSON.stringify({ view: "list", order: ["e2", "e1"] }), + ), + ).toEqual({ view: "list", order: ["e2", "e1"] }); + expect(legacyModuleListPatch("vorsorge", null)).toEqual({}); + }); +}); + +describe("carryOverLegacyModuleListPrefs", () => { + it("sends a browser-stored order up once and removes the browser copy after the server confirmed it", async () => { + store.set( + legacyModuleListStorageKey("illness"), + JSON.stringify({ view: "cards", order: ["e2", "e1"] }), + ); + const saved = resolveModuleListLayout({ illness: { order: ["e2", "e1"] } }); + vi.mocked(apiPut).mockResolvedValue(saved); + const queryClient = new QueryClient(); + + await carryOverLegacyModuleListPrefs({ + list: "illness", + layout: resolveModuleListLayout(null), + queryClient, + }); + + expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", { + version: 1, + list: "illness", + order: ["e2", "e1"], + }); + expect(store.has(legacyModuleListStorageKey("illness"))).toBe(false); + expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual( + saved, + ); + + // A second mount in the same page load does not send it again. + await carryOverLegacyModuleListPrefs({ + list: "illness", + layout: saved, + queryClient, + }); + expect(apiPut).toHaveBeenCalledTimes(1); + }); + + it("keeps the browser copy when the write fails, so the order is never lost", async () => { + store.set( + legacyModuleListStorageKey("labs"), + JSON.stringify({ order: ["m1"], sortDir: "manual" }), + ); + vi.mocked(apiPut).mockRejectedValue(new Error("offline")); + + await carryOverLegacyModuleListPrefs({ + list: "labs", + layout: resolveModuleListLayout(null), + queryClient: new QueryClient(), }); + + expect(store.has(legacyModuleListStorageKey("labs"))).toBe(true); + }); + + it("leaves a list the server already holds alone and drops the stale browser copy", async () => { + store.set( + legacyModuleListStorageKey("vorsorge"), + JSON.stringify({ order: ["r9"] }), + ); + await carryOverLegacyModuleListPrefs({ + list: "vorsorge", + layout: resolveModuleListLayout({ vorsorge: { order: ["r1"] } }), + queryClient: new QueryClient(), + }); + expect(apiPut).not.toHaveBeenCalled(); + expect(store.has(legacyModuleListStorageKey("vorsorge"))).toBe(false); + }); +}); + +describe("runSetModuleListPrefs", () => { + const t = (key: string) => key; - it("places ordered ids first, then the server-default tail", () => { - const result = applyOrder(items, ["c", "a"], idOf); - expect(result.map(idOf)).toEqual(["c", "a", "b"]); + it("applies the change at once and confirms a view change with a toast", async () => { + const queryClient = new QueryClient(); + const saved = resolveModuleListLayout({ labs: { view: "cards" } }); + vi.mocked(apiPut).mockResolvedValue(saved); + await runSetModuleListPrefs({ + list: "labs", + patch: { view: "cards" }, + queryClient, + t, }); + expect(toastWrittenOutcome).toHaveBeenCalledWith( + "success", + "settings.saved", + ); + expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual( + saved, + ); + }); - it("ignores ids in the order that are absent from the data", () => { - const result = applyOrder(items, ["z", "b"], idOf); - expect(result.map(idOf)).toEqual(["b", "a", "c"]); + it("rolls back and says so when the write fails", async () => { + const queryClient = new QueryClient(); + const before = resolveModuleListLayout(null); + queryClient.setQueryData(queryKeys.moduleListLayout(), before); + vi.mocked(apiPut).mockRejectedValue(new Error("nope")); + await runSetModuleListPrefs({ + list: "illness", + patch: { order: ["x"] }, + queryClient, + t, }); + expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual( + before, + ); + expect(showErrorToast).toHaveBeenCalled(); + }); +}); + +describe("applyOrder", () => { + const items = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const idOf = (i: { id: string }) => i.id; + + it("keeps the input order with no saved order", () => { + expect(applyOrder(items, [], idOf)).toEqual(items); + }); + + it("puts ordered ids first and the rest after in input order", () => { + expect(applyOrder(items, ["c", "a"], idOf).map(idOf)).toEqual([ + "c", + "a", + "b", + ]); + }); + + it("drops unknown ids", () => { + expect(applyOrder(items, ["z", "b"], idOf).map(idOf)).toEqual([ + "b", + "a", + "c", + ]); }); }); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index f02b0d0e7..601edaefd 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -484,6 +484,7 @@ export const USER_RESET = { medicationListLayoutJson: Prisma.DbNull, moodTagLayoutJson: Prisma.DbNull, documentsLayoutJson: Prisma.DbNull, + moduleListLayoutJson: Prisma.DbNull, reportSelectionJson: Prisma.DbNull, modulePreferencesJson: Prisma.DbNull, healthScoreConfigJson: Prisma.DbNull, @@ -510,6 +511,8 @@ export const USER_KEPT_FIELDS: Readonly> = { oidcIssuer: "a sign-in credential — the external identity binding", oidcSub: "a sign-in credential — the external identity binding", createdAt: "when the account was created; the account is preserved", + previewFeaturesJson: + "which preview features the person opted into — how the interface is shown, not record content", updatedAt: "row bookkeeping maintained by Prisma", managedProfileAt: "whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.", diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index 2585a2c11..6fb173682 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -503,6 +503,8 @@ const ACCOUNT_SETTING_CODECS: Readonly< medicationListLayoutJson: json("medicationListLayoutJson"), moodTagLayoutJson: json("moodTagLayoutJson"), documentsLayoutJson: json("documentsLayoutJson"), + moduleListLayoutJson: json("moduleListLayoutJson"), + previewFeaturesJson: json("previewFeaturesJson"), reportSelectionJson: json("reportSelectionJson"), globalExcludedInjectionSites: excludedSites, healthKitConfigJson: json("healthKitConfigJson"), diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index b3c9ddef3..b2abed39c 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -1010,6 +1010,8 @@ export const USER_COLUMN_BACKUP_CLASS = { medicationListLayoutJson: "SETTING", moodTagLayoutJson: "SETTING", documentsLayoutJson: "SETTING", + moduleListLayoutJson: "SETTING", + previewFeaturesJson: "SETTING", reportSelectionJson: "SETTING", globalExcludedInjectionSites: "SETTING", healthKitConfigJson: "SETTING", diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts index ff5e45e11..bbed3e12f 100644 --- a/src/lib/module-list-prefs.ts +++ b/src/lib/module-list-prefs.ts @@ -1,109 +1,103 @@ "use client"; /** - * v1.18.6 — client-side per-module list preferences for the - * three secondary modules (Vorsorge, Illness, Labs). + * Per-list presentation for the labs, illness and checkups lists: card or + * list view, a manual order, and (labs only) the sort. * - * These modules do not carry a server-side list-layout column the way - * Medications does (`/api/medications/layout`). Rather than mint three new - * Prisma columns + routes — which a parallel backend phase owns — the - * reorder + view choice live in `localStorage`, keyed per module. The choice - * is presentational only: it reorders / reshapes what the page already - * fetched and never feeds a server read, so a missing or stale blob degrades - * to the server's own default order. + * The choices live on the server (`GET/PUT /api/settings/module-list-layout`, + * `User.moduleListLayoutJson`) so they follow a person to every device. Until + * v1.44 they lived in this browser's `localStorage`, one key per list. Those + * values are carried over, never reset: when the server holds nothing for a + * list and this browser still has a value, the value is sent up once and the + * browser copy is removed after the server confirmed it. While the first read + * is in flight the browser copy (or the default) paints, so a person who + * arranged a list does not see it jump back to the default order. * - * Shape per module (one localStorage key each): - * { view: "cards" | "list", order: string[] } - * `order` is an id allow-list; ids absent from the live data are ignored and - * ids present in the data but absent from `order` sort after the ordered - * block (the server default order is preserved for the tail). + * The choice is presentational only: it reorders or reshapes what the page + * already fetched, so a missing or stale value degrades to the server order. */ -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo } from "react"; +import { + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; -export const MODULE_LIST_VIEWS = ["cards", "list"] as const; -export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number]; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { useMounted } from "@/hooks/use-mounted"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { + isModuleListView, + isModuleSortDir, + moduleListDefaults, + normaliseModuleListOrder, + resolveModuleListLayout, + type ModuleListKey, + type ModuleListLayout, + type ModuleListPrefs, + type ModuleListView, + type ModuleSortDir, +} from "@/lib/settings/module-list-layout"; -export type ModuleListKey = "vorsorge" | "illness" | "labs"; +export type { + ModuleListKey, + ModuleListPrefs, + ModuleListView, + ModuleSortDir, +} from "@/lib/settings/module-list-layout"; + +const ENDPOINT = "/api/settings/module-list-layout"; + +/** The browser key earlier releases stored a list's choices under. */ +export function legacyModuleListStorageKey(list: ModuleListKey): string { + return `healthlog:module-list-prefs:${list}`; +} /** - * Sort choice for the Labs surface (MOD-04): order biomarker groups by the - * recency of their most-recent reading, alphabetically by analyte name - * (#43), or `manual`, which defers to the persisted `order` array. + * Parse a browser-stored blob from before the server column. Anything + * malformed collapses onto the defaults field by field. */ -export const MODULE_SORT_DIRS = [ - "recentDesc", - "recentAsc", - "alphaAsc", - "alphaDesc", - "manual", -] as const; -export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number]; - -export interface ModuleListPrefs { - view: ModuleListView; - order: string[]; - /** Only consumed by the Labs surface; defaults to most-recent-first. */ - sortDir: ModuleSortDir; -} - -const DEFAULT_PREFS: ModuleListPrefs = { - view: "cards", - order: [], - sortDir: "recentDesc", -}; - -/** - * Per-module default overrides applied when no preference blob is stored yet. - * Labs defaults to the compact list view (#40) so the dense reading table is - * the first thing the user sees; Vorsorge and Illness keep the card grid. - */ -const MODULE_DEFAULTS: Partial< - Record> -> = { - labs: { view: "list" }, -}; - -function defaultsFor(module: ModuleListKey): ModuleListPrefs { - return { ...DEFAULT_PREFS, ...MODULE_DEFAULTS[module] }; -} - -function storageKey(module: ModuleListKey): string { - return `healthlog:module-list-prefs:${module}`; -} - -function isView(value: unknown): value is ModuleListView { - return ( - typeof value === "string" && - (MODULE_LIST_VIEWS as readonly string[]).includes(value) - ); -} - -function isSortDir(value: unknown): value is ModuleSortDir { - return ( - typeof value === "string" && - (MODULE_SORT_DIRS as readonly string[]).includes(value) - ); -} - export function parseModuleListPrefs( raw: string | null, - defaults: ModuleListPrefs = DEFAULT_PREFS, + defaults: ModuleListPrefs, ): ModuleListPrefs { if (!raw) return defaults; try { const blob = JSON.parse(raw) as Record; - const view = isView(blob.view) ? blob.view : defaults.view; - const order = Array.isArray(blob.order) - ? blob.order.filter((v): v is string => typeof v === "string") - : []; - const sortDir = isSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir; - return { view, order, sortDir }; + return { + view: isModuleListView(blob.view) ? blob.view : defaults.view, + order: normaliseModuleListOrder(blob.order) ?? [], + sortDir: isModuleSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir, + }; } catch { return defaults; } } +/** + * The fields of a browser-stored value worth carrying over: the ones that + * differ from the default. The old blob always held all three fields, so a + * field equal to the default cannot be told apart from one never chosen and + * is left to follow the default. + */ +export function legacyModuleListPatch( + list: ModuleListKey, + raw: string | null, +): Partial { + if (!raw) return {}; + const defaults = moduleListDefaults(list); + const legacy = parseModuleListPrefs(raw, defaults); + return { + ...(legacy.view !== defaults.view ? { view: legacy.view } : {}), + ...(legacy.order.length > 0 ? { order: legacy.order } : {}), + ...(legacy.sortDir !== defaults.sortDir ? { sortDir: legacy.sortDir } : {}), + }; +} + /** * Apply a persisted id order to a live list. Items whose id appears in * `order` come first in that order; everything else keeps the input order @@ -127,84 +121,165 @@ export function applyOrder( return [...ordered, ...rest]; } -// A module-scoped event so every mounted hook for the same module repaints -// when one writer flushes a change (the settings page + the module page can -// both be alive across a client navigation without a full reload). -const listeners = new Set<() => void>(); -function emit() { - for (const l of listeners) l(); +function readLegacy(list: ModuleListKey): string | null { + try { + return window.localStorage.getItem(legacyModuleListStorageKey(list)); + } catch { + return null; + } +} + +function removeLegacy(list: ModuleListKey): void { + try { + window.localStorage.removeItem(legacyModuleListStorageKey(list)); + } catch { + // Storage unavailable: nothing to remove. + } +} + +/** Lists already carried over (or checked) in this page load. */ +const carriedOver = new Set(); + +/** + * Carry a browser-stored value up to the server, once, when the server holds + * nothing for the list. A failed write leaves the browser copy in place and + * tries again on the next mount; a list the server already holds keeps the + * server value and drops the stale browser copy. + */ +export async function carryOverLegacyModuleListPrefs(deps: { + list: ModuleListKey; + layout: ModuleListLayout; + queryClient: QueryClient; +}): Promise { + const { list, layout, queryClient } = deps; + if (typeof window === "undefined" || carriedOver.has(list)) return; + carriedOver.add(list); + const raw = readLegacy(list); + if (raw === null) return; + if (layout.lists[list].saved) { + removeLegacy(list); + return; + } + const patch = legacyModuleListPatch(list, raw); + if (Object.keys(patch).length === 0) { + removeLegacy(list); + return; + } + try { + const saved = await apiPut(ENDPOINT, { + version: 1, + list, + ...patch, + }); + if (saved) queryClient.setQueryData(queryKeys.moduleListLayout(), saved); + removeLegacy(list); + } catch { + carriedOver.delete(list); + } +} + +/** Forget which lists were carried over. Tests only. */ +export function resetModuleListCarryOverForTests(): void { + carriedOver.clear(); +} + +type Translator = ( + key: string, + params?: Record, +) => string; + +/** + * Apply one field optimistically (the control has to feel instant), then + * persist it. On failure the cache rolls back and a toast says so; a view or + * sort change confirms with a toast, an order change is its own feedback. + */ +export async function runSetModuleListPrefs(deps: { + list: ModuleListKey; + patch: Partial; + queryClient: QueryClient; + t: Translator; +}): Promise { + const { list, patch, queryClient, t } = deps; + const key = queryKeys.moduleListLayout(); + if (queryClient.isFetching({ queryKey: key }) > 0) { + await queryClient.cancelQueries({ queryKey: key }); + } + const previous = queryClient.getQueryData(key); + const base = previous ?? resolveModuleListLayout(null); + queryClient.setQueryData(key, { + ...base, + lists: { + ...base.lists, + [list]: { ...base.lists[list], ...patch, saved: true }, + }, + }); + try { + const saved = await apiPut(ENDPOINT, { + version: 1, + list, + ...patch, + }); + if (saved) queryClient.setQueryData(key, saved); + if (patch.view !== undefined || patch.sortDir !== undefined) { + toastWrittenOutcome("success", t("settings.saved")); + } + } catch (failure) { + queryClient.setQueryData(key, previous); + showErrorToast(t("settings.savingError"), { cause: failure }); + } } /** - * Read + write the per-module list preferences. SSR-safe via - * `useSyncExternalStore` with a null server snapshot (parsed to defaults). + * Read + write one list's presentation. The server value once it is read; + * before that, the browser copy from an earlier release, or the default. */ -export function useModuleListPrefs(module: ModuleListKey): { +export function useModuleListPrefs(list: ModuleListKey): { prefs: ModuleListPrefs; setView: (view: ModuleListView) => void; setOrder: (order: string[]) => void; setSortDir: (sortDir: ModuleSortDir) => void; } { - const key = storageKey(module); - const defaults = useMemo(() => defaultsFor(module), [module]); + const queryClient = useQueryClient(); + const { t } = useTranslations(); - const subscribe = useCallback( - (onChange: () => void) => { - listeners.add(onChange); - const onStorage = (e: StorageEvent) => { - if (e.key === null || e.key === key) onChange(); - }; - if (typeof window !== "undefined") { - window.addEventListener("storage", onStorage); - } - return () => { - listeners.delete(onChange); - if (typeof window !== "undefined") { - window.removeEventListener("storage", onStorage); - } - }; - }, - [key], - ); + const { data } = useQuery({ + queryKey: queryKeys.moduleListLayout(), + queryFn: () => apiGet(ENDPOINT), + staleTime: 5 * 60 * 1000, + }); - const getSnapshot = useCallback(() => { - if (typeof window === "undefined") return null; - return window.localStorage.getItem(key); - }, [key]); + useEffect(() => { + if (!data) return; + void carryOverLegacyModuleListPrefs({ list, layout: data, queryClient }); + }, [data, list, queryClient]); - const raw = useSyncExternalStore(subscribe, getSnapshot, () => null); - // `useSyncExternalStore` returns a stable `raw` string when storage hasn't - // changed, so keying the parse on it gives downstream `applyOrder` - // memoisation a stable `prefs` reference instead of a fresh object + a - // JSON.parse on every render. - const prefs = useMemo( - () => parseModuleListPrefs(raw, defaults), - [raw, defaults], - ); + // The browser copy is read only after hydration, so the server render and + // the first client paint agree (React #418). + const mounted = useMounted(); + const entry = data?.lists[list]; + const prefs = useMemo(() => { + if (entry) { + return { view: entry.view, order: entry.order, sortDir: entry.sortDir }; + } + const defaults = moduleListDefaults(list); + if (!mounted) return defaults; + return parseModuleListPrefs(readLegacy(list), defaults); + }, [entry, list, mounted]); const write = useCallback( - (next: ModuleListPrefs) => { - if (typeof window === "undefined") return; - window.localStorage.setItem(key, JSON.stringify(next)); - emit(); - }, - [key], + (patch: Partial) => + void runSetModuleListPrefs({ list, patch, queryClient, t }), + [list, queryClient, t], ); const setView = useCallback( - (view: ModuleListView) => - write({ ...parseModuleListPrefs(getSnapshot(), defaults), view }), - [write, getSnapshot, defaults], - ); - const setOrder = useCallback( - (order: string[]) => - write({ ...parseModuleListPrefs(getSnapshot(), defaults), order }), - [write, getSnapshot, defaults], + (view: ModuleListView) => write({ view }), + [write], ); + const setOrder = useCallback((order: string[]) => write({ order }), [write]); const setSortDir = useCallback( - (sortDir: ModuleSortDir) => - write({ ...parseModuleListPrefs(getSnapshot(), defaults), sortDir }), - [write, getSnapshot, defaults], + (sortDir: ModuleSortDir) => write({ sortDir }), + [write], ); return { prefs, setView, setOrder, setSortDir }; diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index a837dbdba..5b1ac7c2b 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -23,6 +23,68 @@ import { stdResponses, } from "./shared"; import { WEBHOOK_PAYLOAD_FORMATS } from "@/lib/notifications/types"; +import { + MODULE_LIST_VIEWS, + MODULE_SORT_DIRS, +} from "@/lib/settings/module-list-layout"; +import { + moduleListLayoutPutSchema, + previewFeaturesPatchSchema, +} from "@/lib/validations/settings-prefs"; + +const moduleListPrefsEntry = z + .object({ + view: z + .enum(MODULE_LIST_VIEWS) + .describe( + '`cards` renders the card grid, `list` the compact list. Default "list" for labs, "cards" otherwise.', + ), + order: z + .array(z.string()) + .describe( + "Manual order as an id allow-list. Ids missing from the live data are ignored; items missing from the list follow in the server order.", + ), + sortDir: z + .enum(MODULE_SORT_DIRS) + .describe( + 'Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it.', + ), + saved: z + .boolean() + .describe( + "True when the server holds a choice for this list; false means every field is a default.", + ), + }) + .meta({ id: "ModuleListPrefs" }); + +const moduleListLayoutSchema = z + .object({ + version: z.literal(1), + lists: z.object({ + labs: moduleListPrefsEntry, + illness: moduleListPrefsEntry, + vorsorge: moduleListPrefsEntry, + }), + }) + .meta({ + id: "ModuleListLayout", + description: + "Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.", + }); + +const previewFeaturesSchema = z + .object({ + topics: z + .boolean() + .describe( + "Topic overview pages above the single-metric Insights pages. Off by default.", + ), + }) + .meta({ + id: "PreviewFeatures", + description: + "Per-user opt-in to preview features, one boolean per flag, every flag off by default.", + }); // v1.16.11 — the one threshold read every dose-status consumer makes // (cards, table, take-all-due derivation). `lateMinutes` / @@ -856,6 +918,109 @@ export const settingsPaths: NonNullable = { }, }, }, + "/api/settings/module-list-layout": { + get: { + tags: ["Meta"], + summary: "Get the list presentation of labs, illness and checkups", + description: + "Every list resolved, defaults filled in, with `saved` per list. Never writes a row.", + responses: { + "200": { + description: "The resolved presentation of every list.", + content: { + "application/json": { + schema: dataEnvelope( + moduleListLayoutSchema, + "ModuleListLayoutEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Meta"], + summary: "Change the presentation of one list", + description: + "Preserve-when-absent: the fields the body leaves out keep their stored value, and every other list stays as stored. Returns every list resolved.", + requestBody: { + required: true, + content: { + "application/json": { + schema: moduleListLayoutPutSchema.meta({ + id: "ModuleListLayoutPutBody", + }), + }, + }, + }, + responses: { + "200": { + description: "The presentation as stored.", + content: { + "application/json": { + schema: dataEnvelope( + moduleListLayoutSchema, + "ModuleListLayoutSavedEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/settings/preview-features": { + get: { + tags: ["Meta"], + summary: "Get the preview-feature opt-ins", + description: "Every flag, off unless the caller switched it on.", + responses: { + "200": { + description: "The opt-ins.", + content: { + "application/json": { + schema: dataEnvelope( + previewFeaturesSchema, + "PreviewFeaturesEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Meta"], + summary: "Switch preview features on or off", + description: + "Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.", + requestBody: { + required: true, + content: { + "application/json": { + schema: previewFeaturesPatchSchema.meta({ + id: "PreviewFeaturesPatchBody", + }), + }, + }, + }, + responses: { + "200": { + description: "The opt-ins as stored.", + content: { + "application/json": { + schema: dataEnvelope( + previewFeaturesSchema, + "PreviewFeaturesSavedEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/settings/privacy-summary": { get: { tags: ["Meta"], diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index 6f9f4e930..95c1a4f13 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -38,6 +38,15 @@ export const settingsKeys = { */ modulesPrefs: () => ["settings", "modules"] as const, + /** + * The labs / illness / checkups list presentation behind + * `GET/PUT /api/settings/module-list-layout` (view, manual order, labs sort). + */ + moduleListLayout: () => ["settings", "module-list-layout"] as const, + + /** The per-user preview-feature opt-ins behind `/api/settings/preview-features`. */ + previewFeatures: () => ["settings", "preview-features"] as const, + /** * v1.35.0 — which pillars count toward the Health Score, behind * `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than diff --git a/src/lib/settings/__tests__/module-list-layout.test.ts b/src/lib/settings/__tests__/module-list-layout.test.ts new file mode 100644 index 000000000..c431a945b --- /dev/null +++ b/src/lib/settings/__tests__/module-list-layout.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + MODULE_LIST_ORDER_MAX, + normaliseModuleListOrder, + resolveModuleListLayout, + storedModuleListLayout, +} from "../module-list-layout"; + +describe("resolveModuleListLayout", () => { + it("answers the defaults, nothing saved, for an empty column", () => { + const layout = resolveModuleListLayout(null); + expect(layout.lists.labs).toEqual({ + view: "list", + order: [], + sortDir: "recentDesc", + saved: false, + }); + expect(layout.lists.illness.view).toBe("cards"); + expect(layout.lists.vorsorge.view).toBe("cards"); + expect(layout.lists.illness.saved).toBe(false); + }); + + it("fills a partial list field by field and marks it saved", () => { + const layout = resolveModuleListLayout({ + version: 1, + illness: { order: ["b", "a"] }, + }); + expect(layout.lists.illness).toEqual({ + view: "cards", + order: ["b", "a"], + sortDir: "recentDesc", + saved: true, + }); + expect(layout.lists.labs.saved).toBe(false); + }); + + it("drops malformed values instead of failing", () => { + const layout = resolveModuleListLayout({ + labs: { view: "grid", sortDir: 3, order: "x" }, + vorsorge: "nonsense", + }); + expect(layout.lists.labs).toEqual({ + view: "list", + order: [], + sortDir: "recentDesc", + saved: false, + }); + expect(layout.lists.vorsorge.saved).toBe(false); + }); +}); + +describe("normaliseModuleListOrder", () => { + it("keeps strings, drops duplicates, empties and over-long ids", () => { + expect( + normaliseModuleListOrder(["a", "a", "", 4, "b", "x".repeat(65)]), + ).toEqual(["a", "b"]); + }); + + it("caps the length", () => { + const ids = Array.from({ length: MODULE_LIST_ORDER_MAX + 10 }, (_, i) => + String(i), + ); + expect(normaliseModuleListOrder(ids)).toHaveLength(MODULE_LIST_ORDER_MAX); + }); + + it("answers null for a non-array", () => { + expect(normaliseModuleListOrder("a")).toBeNull(); + }); +}); + +describe("storedModuleListLayout", () => { + it("lists only the lists that carry a choice", () => { + expect( + storedModuleListLayout({ labs: { sortDir: "manual" }, illness: {} }), + ).toEqual({ labs: { sortDir: "manual" } }); + }); +}); diff --git a/src/lib/settings/__tests__/preview-features.test.ts b/src/lib/settings/__tests__/preview-features.test.ts new file mode 100644 index 000000000..df5251b6d --- /dev/null +++ b/src/lib/settings/__tests__/preview-features.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_PREVIEW_FEATURES, + resolvePreviewFeatures, +} from "../preview-features"; + +describe("resolvePreviewFeatures", () => { + it("is all off by default", () => { + expect(DEFAULT_PREVIEW_FEATURES).toEqual({ topics: false }); + expect(resolvePreviewFeatures(null)).toEqual({ topics: false }); + }); + + it("reads a flag only on an explicit true", () => { + expect(resolvePreviewFeatures({ topics: true })).toEqual({ topics: true }); + expect(resolvePreviewFeatures({ topics: "true" })).toEqual({ + topics: false, + }); + }); + + it("drops unknown keys", () => { + expect(resolvePreviewFeatures({ topics: true, retired: true })).toEqual({ + topics: true, + }); + }); +}); diff --git a/src/lib/settings/module-list-layout.ts b/src/lib/settings/module-list-layout.ts new file mode 100644 index 000000000..2783db463 --- /dev/null +++ b/src/lib/settings/module-list-layout.ts @@ -0,0 +1,163 @@ +/** + * List presentation for the labs, illness and checkups lists, persisted in + * `User.moduleListLayoutJson`. + * + * Three independent choices per list, all display-only: + * + * - `view`: the card grid or the compact list. + * - `order`: an id allow-list. Ids absent from the live data are ignored; + * ids present in the data but absent from `order` sort after the ordered + * block in the server's own order. + * - `sortDir`: only the labs list reads it (recency, alphabetical, or the + * manual `order`). + * + * Null / missing column = the defaults below. The GET never lazy-writes, and + * the PUT stores only the fields a person has actually chosen, so a default + * that changes later still reaches every list nobody arranged. Mirrors + * `documents-layout.ts`: tolerant resolver on read, preserve-when-absent PUT. + * + * `saved` tells the client whether the server holds a choice for a list. The + * same choices lived in the browser before this column existed; the client + * copies a browser value up once, only while `saved` is false, so an order a + * person built is carried over instead of reset. + */ + +export const MODULE_LIST_KEYS = ["labs", "illness", "vorsorge"] as const; +export type ModuleListKey = (typeof MODULE_LIST_KEYS)[number]; + +export const MODULE_LIST_VIEWS = ["cards", "list"] as const; +export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number]; + +export const MODULE_SORT_DIRS = [ + "recentDesc", + "recentAsc", + "alphaAsc", + "alphaDesc", + "manual", +] as const; +export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number]; + +/** Longest order a list keeps; a longer one is cut, never refused. */ +export const MODULE_LIST_ORDER_MAX = 500; +/** Longest id an order entry may carry (cuid and uuid fit comfortably). */ +export const MODULE_LIST_ID_MAX = 64; + +export interface ModuleListPrefs { + view: ModuleListView; + order: string[]; + sortDir: ModuleSortDir; +} + +export interface ModuleListPrefsEntry extends ModuleListPrefs { + /** True when the server holds at least one choice for this list. */ + saved: boolean; +} + +export interface ModuleListLayout { + version: 1; + lists: Record; +} + +const BASE_DEFAULTS: ModuleListPrefs = { + view: "cards", + order: [], + sortDir: "recentDesc", +}; + +/** Labs opens on the compact list, the dense reading table (#40). */ +const LIST_DEFAULTS: Record = { + labs: { ...BASE_DEFAULTS, view: "list" }, + illness: { ...BASE_DEFAULTS }, + vorsorge: { ...BASE_DEFAULTS }, +}; + +export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs { + const defaults = LIST_DEFAULTS[list]; + return { ...defaults, order: [...defaults.order] }; +} + +export function isModuleListKey(value: unknown): value is ModuleListKey { + return ( + typeof value === "string" && + (MODULE_LIST_KEYS as readonly string[]).includes(value) + ); +} + +export function isModuleListView(value: unknown): value is ModuleListView { + return ( + typeof value === "string" && + (MODULE_LIST_VIEWS as readonly string[]).includes(value) + ); +} + +export function isModuleSortDir(value: unknown): value is ModuleSortDir { + return ( + typeof value === "string" && + (MODULE_SORT_DIRS as readonly string[]).includes(value) + ); +} + +/** Keep string ids of a sane length, first occurrence wins, capped. */ +export function normaliseModuleListOrder(raw: unknown): string[] | null { + if (!Array.isArray(raw)) return null; + const seen = new Set(); + const out: string[] = []; + for (const value of raw) { + if (typeof value !== "string") continue; + if (value.length === 0 || value.length > MODULE_LIST_ID_MAX) continue; + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + if (out.length >= MODULE_LIST_ORDER_MAX) break; + } + return out; +} + +/** + * The fields a stored blob explicitly carries for one list, each validated. + * Unknown or malformed values drop out, so the default fills them on read. + */ +export function storedModuleListChoices( + raw: unknown, +): Partial { + if (raw === null || raw === undefined || typeof raw !== "object") return {}; + const blob = raw as { view?: unknown; order?: unknown; sortDir?: unknown }; + const order = normaliseModuleListOrder(blob.order); + return { + ...(isModuleListView(blob.view) ? { view: blob.view } : {}), + ...(order !== null ? { order } : {}), + ...(isModuleSortDir(blob.sortDir) ? { sortDir: blob.sortDir } : {}), + }; +} + +/** Every list's stored choices, keyed by list. */ +export function storedModuleListLayout( + raw: unknown, +): Partial>> { + if (raw === null || raw === undefined || typeof raw !== "object") return {}; + const blob = raw as Record; + const out: Partial>> = {}; + for (const list of MODULE_LIST_KEYS) { + const choices = storedModuleListChoices(blob[list]); + if (Object.keys(choices).length > 0) out[list] = choices; + } + return out; +} + +/** + * Any malformed, partial or legacy blob collapses onto the defaults field by + * field, so a GET never fails on a stored row. + */ +export function resolveModuleListLayout(raw: unknown): ModuleListLayout { + const stored = storedModuleListLayout(raw); + const lists = {} as Record; + for (const list of MODULE_LIST_KEYS) { + const choices = stored[list]; + lists[list] = { + ...moduleListDefaults(list), + ...choices, + saved: choices !== undefined, + }; + } + return { version: 1, lists }; +} diff --git a/src/lib/settings/preview-features.ts b/src/lib/settings/preview-features.ts new file mode 100644 index 000000000..cd184520d --- /dev/null +++ b/src/lib/settings/preview-features.ts @@ -0,0 +1,31 @@ +/** + * Per-user opt-in to preview features, persisted in `User.previewFeaturesJson`. + * + * One boolean per flag, every flag off by default. A preview feature is + * something finished enough to try and not yet the default; switching one on + * adds it beside what is there and never replaces an existing page. + * + * Flags: + * - `topics`: topic overview pages above the single-metric Insights pages. + * Stored and shown in Settings > System; no surface reads it yet. + * + * Unknown keys in a stored blob are dropped on read, so a flag retired later + * disappears cleanly and a malformed blob reads as everything off. + */ + +export const PREVIEW_FEATURE_KEYS = ["topics"] as const; +export type PreviewFeatureKey = (typeof PREVIEW_FEATURE_KEYS)[number]; + +export type PreviewFeatures = Record; + +export const DEFAULT_PREVIEW_FEATURES: PreviewFeatures = { topics: false }; + +export function resolvePreviewFeatures(raw: unknown): PreviewFeatures { + const out: PreviewFeatures = { ...DEFAULT_PREVIEW_FEATURES }; + if (raw === null || raw === undefined || typeof raw !== "object") return out; + const blob = raw as Record; + for (const key of PREVIEW_FEATURE_KEYS) { + if (blob[key] === true) out[key] = true; + } + return out; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 957caaf9e..4d3aac9f4 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -2154,6 +2154,8 @@ const accountSettingsBackupSchema = z medicationListLayoutJson: z.unknown().optional(), moodTagLayoutJson: z.unknown().optional(), documentsLayoutJson: z.unknown().optional(), + moduleListLayoutJson: z.unknown().optional(), + previewFeaturesJson: z.unknown().optional(), reportSelectionJson: z.unknown().optional(), globalExcludedInjectionSites: z.array(z.string()).optional(), healthKitConfigJson: z.unknown().optional(), diff --git a/src/lib/validations/settings-prefs.ts b/src/lib/validations/settings-prefs.ts new file mode 100644 index 000000000..c3cc82112 --- /dev/null +++ b/src/lib/validations/settings-prefs.ts @@ -0,0 +1,37 @@ +/** + * Request schemas for the per-user Settings preferences that are not part of + * the account payload: the list presentation of the labs, illness and + * checkups lists, and the preview-feature opt-ins. Shared by the routes and + * the OpenAPI registry so the wire contract has one source. + */ +import { z } from "zod/v4"; + +import { + MODULE_LIST_ID_MAX, + MODULE_LIST_KEYS, + MODULE_LIST_ORDER_MAX, + MODULE_LIST_VIEWS, + MODULE_SORT_DIRS, +} from "@/lib/settings/module-list-layout"; + +export const moduleListLayoutPutSchema = z + .object({ + version: z.literal(1), + list: z.enum(MODULE_LIST_KEYS), + view: z.enum(MODULE_LIST_VIEWS).optional(), + order: z + .array(z.string().min(1).max(MODULE_LIST_ID_MAX)) + .max(MODULE_LIST_ORDER_MAX) + .optional(), + sortDir: z.enum(MODULE_SORT_DIRS).optional(), + }) + .strict(); + +export const previewFeaturesPatchSchema = z + .object({ + topics: z.boolean().optional(), + }) + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: "Name at least one preview feature.", + }); diff --git a/tests/integration/settings-prefs-routes.test.ts b/tests/integration/settings-prefs-routes.test.ts new file mode 100644 index 000000000..5dcc4d7c5 --- /dev/null +++ b/tests/integration/settings-prefs-routes.test.ts @@ -0,0 +1,216 @@ +/** + * The two per-user Settings preferences that live outside the account + * payload, end to end against Postgres: + * + * - `/api/settings/module-list-layout`: defaults without a write, one list + * changed per PUT with preserve-when-absent fields and untouched neighbours, + * the multi-issue 422 for unknown values, and the column carrying only what + * was chosen. + * - `/api/settings/preview-features`: all off by default, PATCH keeps the + * flags it does not name, an unknown flag or an empty body is refused. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedUser(): Promise<{ userId: string }> { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "settings-prefs-user", + email: "settings-prefs@example.test", + role: "USER", + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return { userId: user.id }; +} + +type Handler = (req: Request) => Promise; + +function json(method: string, url: string, body: unknown): Request { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/settings/module-list-layout — integration", () => { + const URL = "http://localhost/api/settings/module-list-layout"; + + it("answers the defaults without writing a row", async () => { + const { userId } = await seedUser(); + const { GET } = await import("@/app/api/settings/module-list-layout/route"); + const res = await (GET as Handler)(new Request(URL)); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { lists: Record }; + }; + expect(body.data.lists.labs).toMatchObject({ view: "list", saved: false }); + expect(body.data.lists.illness).toMatchObject({ + view: "cards", + saved: false, + }); + const row = await getPrismaClient().user.findUnique({ + where: { id: userId }, + select: { moduleListLayoutJson: true }, + }); + expect(row?.moduleListLayoutJson).toBeNull(); + }); + + it("changes one list per PUT and keeps the fields and lists it does not name", async () => { + const { userId } = await seedUser(); + const { GET, PUT } = + await import("@/app/api/settings/module-list-layout/route"); + + const first = await (PUT as Handler)( + json("PUT", URL, { version: 1, list: "labs", order: ["m2", "m1"] }), + ); + expect(first.status).toBe(200); + const second = await (PUT as Handler)( + json("PUT", URL, { version: 1, list: "labs", sortDir: "manual" }), + ); + expect(second.status).toBe(200); + await (PUT as Handler)( + json("PUT", URL, { version: 1, list: "illness", view: "list" }), + ); + + const res = await (GET as Handler)(new Request(URL)); + const body = (await res.json()) as { + data: { + lists: Record< + string, + { view: string; order: string[]; sortDir: string; saved: boolean } + >; + }; + }; + expect(body.data.lists.labs).toEqual({ + view: "list", + order: ["m2", "m1"], + sortDir: "manual", + saved: true, + }); + expect(body.data.lists.illness).toMatchObject({ + view: "list", + order: [], + saved: true, + }); + expect(body.data.lists.vorsorge.saved).toBe(false); + + // Only chosen fields are stored, so a default can still move later. + const row = await getPrismaClient().user.findUnique({ + where: { id: userId }, + select: { moduleListLayoutJson: true }, + }); + expect(row?.moduleListLayoutJson).toEqual({ + version: 1, + labs: { order: ["m2", "m1"], sortDir: "manual" }, + illness: { view: "list" }, + }); + }); + + it("refuses unknown lists, values and keys with every issue named", async () => { + await seedUser(); + const { PUT } = await import("@/app/api/settings/module-list-layout/route"); + const res = await (PUT as Handler)( + json("PUT", URL, { + version: 1, + list: "weight", + view: "grid", + extra: true, + }), + ); + expect(res.status).toBe(422); + }); + + it("refuses a caller without a session", async () => { + const { GET } = await import("@/app/api/settings/module-list-layout/route"); + const res = await (GET as Handler)(new Request(URL)); + expect(res.status).toBe(401); + }); +}); + +describe("/api/settings/preview-features — integration", () => { + const URL = "http://localhost/api/settings/preview-features"; + + it("is all off by default and keeps a flag once switched on", async () => { + const { userId } = await seedUser(); + const { GET, PATCH } = + await import("@/app/api/settings/preview-features/route"); + + const before = await (GET as Handler)(new Request(URL)); + expect(((await before.json()) as { data: unknown }).data).toEqual({ + topics: false, + }); + + const on = await (PATCH as Handler)(json("PATCH", URL, { topics: true })); + expect(on.status).toBe(200); + expect(((await on.json()) as { data: unknown }).data).toEqual({ + topics: true, + }); + + const after = await (GET as Handler)(new Request(URL)); + expect(((await after.json()) as { data: unknown }).data).toEqual({ + topics: true, + }); + const row = await getPrismaClient().user.findUnique({ + where: { id: userId }, + select: { previewFeaturesJson: true }, + }); + expect(row?.previewFeaturesJson).toEqual({ topics: true }); + }); + + it("refuses an unknown flag and an empty body", async () => { + await seedUser(); + const { PATCH } = await import("@/app/api/settings/preview-features/route"); + const unknown = await (PATCH as Handler)( + json("PATCH", URL, { topics: true, darkMagic: true }), + ); + expect(unknown.status).toBe(422); + const empty = await (PATCH as Handler)(json("PATCH", URL, {})); + expect(empty.status).toBe(422); + }); +});