mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user