Merge branch 'feat/v144-settings' into release/v1.44.0

# Conflicts:
#	src/components/settings/privacy-section.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 20:51:39 +02:00
commit e1b70537cc
129 files changed
+5138 -3169

No files matched your search

@@ -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<unknown> {
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);
});
@@ -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<unknown> {
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);
});