mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(insights): server-persist insights tile order + visibility
Mirrors /api/dashboard/widgets for the insights surface: GET returns the user's saved layout (defaulting to project order when unset), PUT writes the layout, the response is the canonical shape the clients can render directly. Closes the cross-device drift where insights ordering lived in iOS local state only. Storage is a new `insights_layout_json` JSONB column on `users`, parallel to `dashboard_widgets_json` so each surface's resolver owns its tile-id allowlist. Migration 0083 adds the column idempotently; default NULL resolves to `DEFAULT_INSIGHTS_LAYOUT`. PUT routes through the shared multi-issue 422 envelope + payload-redaction + audit-row dedup pattern the dashboard-widgets route established. A 5-minute per-user cache mirrors the dashboard-widgets cache and busts on every save / reset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
19cfd69ff1
commit
a75b96b2a9
9 files changed
+763
No files matched your search
@@ -0,0 +1,44 @@
|
||||
-- v1.5.5 — per-user insights tile layout.
|
||||
--
|
||||
-- Mirrors `dashboard_widgets_json` for the `/insights` surface. iOS
|
||||
-- v0.8.0 audit flagged that insights tile order + visibility lived in
|
||||
-- the iOS client's local state only, so reordering a tile on one
|
||||
-- device never propagated to the web Settings UI or to a second iOS
|
||||
-- install signed into the same account. This column closes that
|
||||
-- cross-device drift by giving the new `GET / PUT /api/insights/layout`
|
||||
-- endpoint somewhere to persist the user's choice.
|
||||
--
|
||||
-- Shape:
|
||||
-- {
|
||||
-- "version": 1,
|
||||
-- "tiles": [
|
||||
-- { "id": "<tile-id>", "visible": <bool>, "order": <int 0..> },
|
||||
-- …
|
||||
-- ]
|
||||
-- }
|
||||
--
|
||||
-- Default `NULL`, which the application resolver
|
||||
-- (`src/lib/insights-layout.ts:resolveInsightsLayout`) reads as
|
||||
-- "use `DEFAULT_INSIGHTS_LAYOUT`". The GET endpoint never lazy-writes
|
||||
-- on read — the column only carries data once the user has explicitly
|
||||
-- saved a layout, matching the dashboard-widgets contract.
|
||||
--
|
||||
-- Idempotent guard (`IF NOT EXISTS`) matches the 0067 / 0070 / 0071 /
|
||||
-- 0075 / 0078 / 0079 pattern so reruns are safe on environments where
|
||||
-- the migration has already been applied.
|
||||
--
|
||||
-- Reversibility: down migration is
|
||||
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "insights_layout_json";
|
||||
-- A roll-back loses the saved layout for every user who set one (they
|
||||
-- fall back to the default order), but the application reads tolerate
|
||||
-- a missing column / null row and the resolver returns the default
|
||||
-- layout in either case, so a partial deploy where the schema is
|
||||
-- rolled back ahead of the app code keeps working.
|
||||
|
||||
-- No additional index — reads at request time are per-user
|
||||
-- (`WHERE id = $1`), matching the convention set by
|
||||
-- `dashboard_widgets_json`, `thresholds_json`, `source_priority_json`,
|
||||
-- and `doctor_report_prefs_json` on the same table (no GIN index on
|
||||
-- any of them).
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "insights_layout_json" JSONB;
|
||||
@@ -71,6 +71,15 @@ model User {
|
||||
// Per-user dashboard layout — widget visibility + ordering.
|
||||
// Shape: { "widgets": [{ "id": "weight", "visible": true, "order": 0 }, ...], "version": 1 }
|
||||
dashboardWidgetsJson Json? @map("dashboard_widgets_json")
|
||||
// Per-user insights tile layout — tile visibility + ordering for the
|
||||
// `/insights` surface. Mirrors `dashboardWidgetsJson` so the iOS
|
||||
// client and the web Settings UI can persist insights tile order +
|
||||
// visibility through the same shape. Null = default layout (see
|
||||
// `src/lib/insights-layout.ts:DEFAULT_INSIGHTS_LAYOUT`). The GET
|
||||
// endpoint never lazy-writes — the column only carries data once
|
||||
// the user has explicitly saved a layout.
|
||||
// Shape: { "tiles": [{ "id": "blutdruck", "visible": true, "order": 0 }, ...], "version": 1 }
|
||||
insightsLayoutJson Json? @map("insights_layout_json")
|
||||
// Blood-glucose display unit preference. Canonical storage is mg/dL.
|
||||
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
|
||||
// AI provider selection for insights. Null = OPENAI fallback.
|
||||
|
||||
@@ -63,6 +63,12 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
// ability to dispose of stale recommendations.
|
||||
"src/app/api/insights/feedback/route.ts",
|
||||
"src/app/api/insights/glp1-timeline/route.ts",
|
||||
// v1.5.5 — per-user tile layout for the `/insights` surface. The
|
||||
// endpoint persists tile visibility + ordering only; it carries no
|
||||
// assistant prose and is the mirror of `/api/dashboard/widgets`,
|
||||
// which also sits outside the Coach gate. Disabling Coach must not
|
||||
// wedge the user's ability to reorder the insights tile strip.
|
||||
"src/app/api/insights/layout/route.ts",
|
||||
"src/app/api/insights/targets/route.ts",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* v1.5.5 — `/api/insights/layout` route.
|
||||
*
|
||||
* Mirrors the dashboard-widgets test deck so the two surfaces share
|
||||
* the same contract guarantees: multi-issue 422 envelopes, redacted
|
||||
* wide-event payload diagnostics, audit-row dedup, GET returns the
|
||||
* default layout for users who have not saved one, PUT round-trips
|
||||
* through the resolver, DELETE returns the canonical default.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT, DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
|
||||
import { DEFAULT_INSIGHTS_LAYOUT } from "@/lib/insights-layout";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as () => Promise<Response>;
|
||||
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
|
||||
const callDelete = DELETE as unknown as () => Promise<Response>;
|
||||
|
||||
function makeReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/layout", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
__resetAuditDedupMemoForTests();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/layout", () => {
|
||||
it("returns the default layout when the user has no saved row", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: typeof DEFAULT_INSIGHTS_LAYOUT;
|
||||
};
|
||||
expect(body.data.version).toBe(1);
|
||||
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
|
||||
expect(body.data.tiles[0]?.id).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles[0]?.id);
|
||||
});
|
||||
|
||||
it("does not lazy-write a row on GET", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: null,
|
||||
} as never);
|
||||
|
||||
await callGet();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the saved layout when one exists, merged through the resolver", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: {
|
||||
version: 1,
|
||||
tiles: [
|
||||
{ id: "overview", visible: false, order: 0 },
|
||||
{ id: "blutdruck", visible: true, order: 1 },
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: typeof DEFAULT_INSIGHTS_LAYOUT;
|
||||
};
|
||||
// Saved tiles surface in saved order; the resolver appends every
|
||||
// missing tile invisibly so a new release introducing a tile id
|
||||
// doesn't break a returning user's GET.
|
||||
expect(body.data.tiles[0]).toEqual({
|
||||
id: "overview",
|
||||
visible: false,
|
||||
order: 0,
|
||||
});
|
||||
expect(body.data.tiles[1]).toEqual({
|
||||
id: "blutdruck",
|
||||
visible: true,
|
||||
order: 1,
|
||||
});
|
||||
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/insights/layout — happy path", () => {
|
||||
it("persists a normalised layout and returns it verbatim", async () => {
|
||||
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: "puls", visible: false, order: 2 },
|
||||
],
|
||||
};
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { tiles: Array<{ id: string; visible: boolean; order: number }> };
|
||||
};
|
||||
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]?.visible).toBe(false);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const updateCall = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
where: { id: string };
|
||||
data: { insightsLayoutJson: unknown };
|
||||
};
|
||||
expect(updateCall.where.id).toBe("user-1");
|
||||
expect(updateCall.data.insightsLayoutJson).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
|
||||
it("surfaces TWO simultaneous validation errors under details.issues", async () => {
|
||||
const res = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBe(2);
|
||||
const paths = body.details.issues.map((i) => i.path).sort();
|
||||
expect(paths).toEqual(["tiles", "version"]);
|
||||
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unknown tile id", async () => {
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
tiles: [{ id: "not-a-real-tile", visible: true, order: 0 }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) => i.path.startsWith("tiles")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("writes one audit-ledger row keyed insights.layout.validation-failed", async () => {
|
||||
const res = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string; details: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("insights.layout.validation-failed");
|
||||
const details = JSON.parse(call.data.details) as {
|
||||
issues: Array<{ path: string; code: string }>;
|
||||
};
|
||||
expect(details.issues.length).toBe(2);
|
||||
for (const issue of details.issues) {
|
||||
// The persisted row strips `message` so a future Zod code that
|
||||
// echoes the offending value cannot leak into the audit
|
||||
// surface.
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("dedups the audit-ledger write across two sequential 422s for the same user", async () => {
|
||||
const res1 = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
expect(res1.status).toBe(422);
|
||||
const res2 = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
expect(res2.status).toBe(422);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The full multi-issue envelope still rides on every 422 — the
|
||||
// dedup only suppresses the breadcrumb write, never the response.
|
||||
const body2 = (await res2.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body2.details.issues.length).toBe(2);
|
||||
});
|
||||
|
||||
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta", async () => {
|
||||
const payload = { version: 2, tiles: [], extraGarbage: "from-ios" };
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"insights.layout.validation-failed",
|
||||
);
|
||||
expect(annotated, "validation-failed annotate call").toBeTruthy();
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
|
||||
expect(meta.received_keys).toEqual(
|
||||
expect.arrayContaining(["version", "tiles", "extraGarbage"]),
|
||||
);
|
||||
|
||||
expect(typeof meta.received_shape_excerpt).toBe("string");
|
||||
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
|
||||
256,
|
||||
);
|
||||
|
||||
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
|
||||
expect(Array.isArray(issues)).toBe(true);
|
||||
expect(issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts sensitive keys before writing the wide-event received_shape_excerpt", async () => {
|
||||
const payload = {
|
||||
version: 2,
|
||||
tiles: [],
|
||||
apnsToken: "ff".repeat(32),
|
||||
authorization: "Bearer leaked",
|
||||
payload: { apiKey: "sk_live_xxx" },
|
||||
};
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"insights.layout.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
const excerpt = meta.received_shape_excerpt as string;
|
||||
|
||||
expect(excerpt).not.toContain("ff".repeat(32));
|
||||
expect(excerpt).not.toContain("Bearer leaked");
|
||||
expect(excerpt).not.toContain("sk_live_xxx");
|
||||
expect(excerpt).toContain("[redacted]");
|
||||
expect(meta.received_keys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"version",
|
||||
"tiles",
|
||||
"apnsToken",
|
||||
"authorization",
|
||||
"payload",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block the 422 response when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
|
||||
const res = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/insights/layout", () => {
|
||||
it("clears the saved row and returns the default layout", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callDelete();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: typeof DEFAULT_INSIGHTS_LAYOUT;
|
||||
};
|
||||
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
data: { insightsLayoutJson: unknown };
|
||||
};
|
||||
// The Prisma JsonNull sentinel writes NULL into the column.
|
||||
expect(call.data.insightsLayoutJson).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* GET / PUT / DELETE insights tile layout.
|
||||
*
|
||||
* GET returns the resolved effective layout (defaults merged in if the
|
||||
* user hasn't customised yet). PUT replaces the layout atomically.
|
||||
* DELETE resets to default.
|
||||
*
|
||||
* Mirrors the shape and semantics of `/api/dashboard/widgets` so the
|
||||
* iOS client + the Settings UI can persist insights tile order +
|
||||
* visibility through the same contract. The two surfaces stay separate
|
||||
* columns on `User` (`dashboard_widgets_json` / `insights_layout_json`)
|
||||
* to keep each resolver's known-id set authoritative — overloading a
|
||||
* single blob with a `surface` discriminator would force every read to
|
||||
* demux first.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
buildPayloadDiagnostic,
|
||||
safeJson,
|
||||
returnAllZodIssues,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
DEFAULT_INSIGHTS_LAYOUT,
|
||||
INSIGHTS_TILE_IDS,
|
||||
type InsightsLayout,
|
||||
} from "@/lib/insights-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
import { invalidateUserInsightsLayout } from "@/lib/cache/invalidate";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
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);
|
||||
|
||||
const layoutSchema = z.object({
|
||||
version: z.literal(1),
|
||||
tiles: z
|
||||
.array(
|
||||
z.object({
|
||||
id: tileIdEnum,
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
});
|
||||
|
||||
async function buildInsightsLayout(userId: string): Promise<InsightsLayout> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { insightsLayoutJson: true },
|
||||
});
|
||||
return resolveInsightsLayout(row?.insightsLayoutJson);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// 5-minute TTL matches the dashboard-widgets cache; the layout
|
||||
// changes only when the user hits the Settings save button, which
|
||||
// invalidates this cache via `invalidateUserInsightsLayout()`.
|
||||
const layout = await cached(
|
||||
caches.insightsLayout as ServerCache<InsightsLayout>,
|
||||
user.id,
|
||||
() => buildInsightsLayout(user.id),
|
||||
annotate,
|
||||
);
|
||||
return apiSuccess(layout);
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// Multi-issue 422 envelope matches the dashboard-widgets route so
|
||||
// iOS contract debugging surfaces every wrong field in one
|
||||
// round-trip; the wide-event line carries the redacted payload
|
||||
// shape for operator debugging without leaking the raw body.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
|
||||
annotate({
|
||||
action: { name: "insights.layout.validation-failed" },
|
||||
meta: {
|
||||
issue_count: issues.length,
|
||||
...payloadDiagnostic,
|
||||
zod_issues: issues,
|
||||
},
|
||||
});
|
||||
if (
|
||||
shouldEmitAuditRow(
|
||||
user.id,
|
||||
"insights.layout.validation-failed",
|
||||
Date.now(),
|
||||
)
|
||||
) {
|
||||
// Best-effort breadcrumb — never block the 422 on a write miss.
|
||||
// `message` strips from the audit row so a future Zod code that
|
||||
// embeds the offending value cannot leak through the audit
|
||||
// surface (the wide-event excerpt above already carries the
|
||||
// shape signal for debugging).
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "insights.layout.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — validation response is the contract, audit row is best-effort */
|
||||
});
|
||||
}
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const normalized = serializeInsightsLayout(parsed.data);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
insightsLayoutJson: toJson(normalized),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.layout.update" },
|
||||
meta: { visible_count: normalized.tiles.filter((t) => t.visible).length },
|
||||
});
|
||||
|
||||
invalidateUserInsightsLayout(user.id);
|
||||
|
||||
return apiSuccess(normalized);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { insightsLayoutJson: Prisma.JsonNull },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.layout.reset" } });
|
||||
|
||||
invalidateUserInsightsLayout(user.id);
|
||||
|
||||
return apiSuccess(DEFAULT_INSIGHTS_LAYOUT);
|
||||
});
|
||||
Vendored
+10
@@ -84,6 +84,16 @@ export function invalidateUserDashboardWidgets(userId: string): void {
|
||||
caches.dashboardWidgets.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the insights tile-layout cache for a single user. Called
|
||||
* from the insights-layout PUT / DELETE endpoints when the user
|
||||
* reorders / disables a tile so the next `/insights` mount paints the
|
||||
* new layout.
|
||||
*/
|
||||
export function invalidateUserInsightsLayout(userId: string): void {
|
||||
caches.insightsLayout.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global eviction of the bug-report-status cache.
|
||||
*
|
||||
|
||||
Vendored
+12
@@ -260,6 +260,18 @@ export const caches = {
|
||||
maxEntries: 1000,
|
||||
ttlMs: 60_000,
|
||||
}),
|
||||
/**
|
||||
* v1.5.5 — per-user insights tile layout cache. Mirrors
|
||||
* `dashboardWidgets` (same 5-minute TTL, same per-user bucket size).
|
||||
* Read on every `/insights` mount; the layout only changes on a
|
||||
* Settings save, which invalidates this cache via
|
||||
* `invalidateUserInsightsLayout()`.
|
||||
*/
|
||||
insightsLayout: new ServerCache<unknown>({
|
||||
name: "insightsLayout",
|
||||
maxEntries: 500,
|
||||
ttlMs: 300_000,
|
||||
}),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Insights tile layout — persisted in `User.insightsLayoutJson`.
|
||||
*
|
||||
* Single source of truth for which insights pills show on `/insights`,
|
||||
* what order they render in, and what the default layout is for new
|
||||
* users. Null / missing column = default layout below; the GET endpoint
|
||||
* never lazy-writes a row, so the column only carries data once the
|
||||
* user has explicitly saved a layout.
|
||||
*
|
||||
* Mirrors `dashboard-layout.ts` for the dashboard widget surface — same
|
||||
* resolver / serializer semantics, same auto-merge of new tile IDs
|
||||
* introduced in later releases. Keeping the two modules structurally
|
||||
* identical means the iOS client + the Settings UI can share rendering
|
||||
* code paths against either surface.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every insights tile the layout knows about. Each entry maps to a
|
||||
* routed sub-page under `/insights/<slug>` (the mother-page overview
|
||||
* is keyed `"overview"`). The order of entries here is irrelevant —
|
||||
* `DEFAULT_INSIGHTS_LAYOUT.tiles[].order` drives the visual order.
|
||||
* Exported so the API Zod `tileIdEnum` can be built from a single
|
||||
* source of truth.
|
||||
*/
|
||||
export const INSIGHTS_TILE_IDS = [
|
||||
"overview",
|
||||
// ── vitals ──
|
||||
"blutdruck",
|
||||
"puls",
|
||||
"sauerstoff",
|
||||
"koerpertemperatur",
|
||||
// ── body composition ──
|
||||
"gewicht",
|
||||
"bmi",
|
||||
// ── activity ──
|
||||
"aktive-energie",
|
||||
"workouts",
|
||||
// ── sleep ──
|
||||
"schlaf",
|
||||
// ── cardiovascular ──
|
||||
"ruhepuls",
|
||||
"hrv",
|
||||
// ── mood ──
|
||||
"stimmung",
|
||||
// ── events ──
|
||||
"medikamente",
|
||||
] as const;
|
||||
|
||||
export type InsightsTileId = (typeof INSIGHTS_TILE_IDS)[number];
|
||||
|
||||
export interface InsightsTileConfig {
|
||||
id: InsightsTileId;
|
||||
visible: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface InsightsLayout {
|
||||
version: number;
|
||||
tiles: InsightsTileConfig[];
|
||||
}
|
||||
|
||||
const INSIGHTS_LAYOUT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Default layout mirrors the routed tab-strip order — vitals first,
|
||||
* then body composition, activity, sleep, cardiovascular, mood, then
|
||||
* events. Core surfaces are default-visible; the long-tail HealthKit
|
||||
* pills (oxygen saturation, body temperature, active energy, sleep,
|
||||
* resting HR, HRV) stay default-invisible and the user opts in once
|
||||
* the matching data starts to flow. Adding a new tile lands as an
|
||||
* additional `{ id, visible: false, order: N }` row here and the
|
||||
* resolver auto-merges it onto existing users' saved layouts.
|
||||
*/
|
||||
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: "bmi", visible: true, order: 6 },
|
||||
{ id: "aktive-energie", visible: false, order: 7 },
|
||||
{ id: "workouts", visible: true, order: 8 },
|
||||
{ id: "schlaf", visible: false, order: 9 },
|
||||
{ id: "ruhepuls", visible: false, order: 10 },
|
||||
{ id: "hrv", visible: false, order: 11 },
|
||||
{ id: "stimmung", visible: true, order: 12 },
|
||||
{ id: "medikamente", visible: true, order: 13 },
|
||||
],
|
||||
};
|
||||
|
||||
export function resolveInsightsLayout(raw: unknown): InsightsLayout {
|
||||
if (!raw || typeof raw !== "object") return DEFAULT_INSIGHTS_LAYOUT;
|
||||
const candidate = raw as Partial<InsightsLayout>;
|
||||
if (
|
||||
typeof candidate.version !== "number" ||
|
||||
!Array.isArray(candidate.tiles)
|
||||
) {
|
||||
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.
|
||||
const knownIds = new Set<string>(INSIGHTS_TILE_IDS);
|
||||
const filtered = candidate.tiles.filter((t) => knownIds.has(t.id));
|
||||
|
||||
// Merge with defaults so new tiles introduced in later versions show
|
||||
// up automatically (invisible by default, the user opts in).
|
||||
const savedIds = new Set(filtered.map((t) => t.id));
|
||||
const missing = DEFAULT_INSIGHTS_LAYOUT.tiles.filter(
|
||||
(t) => !savedIds.has(t.id),
|
||||
);
|
||||
const maxOrder = Math.max(0, ...filtered.map((t) => t.order));
|
||||
const appended = missing.map((t, i) => ({
|
||||
...t,
|
||||
visible: false, // default-invisible on auto-upgrade
|
||||
order: maxOrder + 1 + i,
|
||||
}));
|
||||
|
||||
return {
|
||||
version: INSIGHTS_LAYOUT_VERSION,
|
||||
tiles: [...filtered, ...appended].sort((a, b) => a.order - b.order),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeInsightsLayout(
|
||||
layout: InsightsLayout,
|
||||
): InsightsLayout {
|
||||
return {
|
||||
version: INSIGHTS_LAYOUT_VERSION,
|
||||
tiles: layout.tiles
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((t, i) => ({
|
||||
id: t.id,
|
||||
visible: t.visible,
|
||||
order: i, // normalize to 0-based dense order
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -262,6 +262,14 @@ export const queryKeys = {
|
||||
*/
|
||||
dashboardWidgets: () => ["user", "dashboardWidgets"] as const,
|
||||
|
||||
/**
|
||||
* v1.5.5 — per-user insights tile layout (mirrors
|
||||
* `dashboardWidgets`). The Settings UI + the iOS client + the
|
||||
* `/insights` shell all read this key; the PUT mutation invalidates
|
||||
* it on save so every consumer paints the new layout in lockstep.
|
||||
*/
|
||||
insightsLayout: () => ["user", "insightsLayout"] as const,
|
||||
|
||||
/**
|
||||
* v1.4.25 W5e — per-user, per-metric-class source priority. The
|
||||
* Settings → Sources surface reads + writes this key; saving
|
||||
|
||||
Reference in new issue
Block a user