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);
});
+1 -1
View File
@@ -153,7 +153,7 @@ export default function LabsPage() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/labs"
href="/settings/modules/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
>
+1 -1
View File
@@ -150,7 +150,7 @@ export default function MoodPageClient() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/mood"
href="/settings/modules/mood"
aria-label={t("mood.customize")}
title={t("mood.customize")}
>
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function SettingsSectionPage({ params }: PageProps) {
}
// Each section is its own chunk (see `SettingsSectionLoader`), so the route
// ships the one it shows rather than all twenty-one. Integrations is the
// ships the one it shows rather than all of them. Integrations is the
// one section that needs a server-resolved value: the OAuth callback URL
// each provider registers, read from the runtime env here, per request,
// because a `NEXT_PUBLIC_*` read inside a client module is inlined at build
@@ -9,6 +9,8 @@ import {
isLayoutGroupId,
} from "@/components/settings/layout-groups";
import { SettingsShell } from "@/components/settings/settings-shell";
import { ModuleSettingsLinkCard } from "@/components/settings/module-settings-pages";
import { isModuleSettingsPageId } from "@/components/settings/module-settings-page-ids";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -86,6 +88,11 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) {
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleKey={layoutGroupModule(group.id)}>
<Body />
{/* v1.44 — what the module needs beyond order and view lives
on its page under Modules; say where. */}
{isModuleSettingsPageId(group.id) ? (
<ModuleSettingsLinkCard id={group.id} />
) : null}
</LayoutModuleGate>
</section>
</RecordSettingsSectionGate>
@@ -0,0 +1,72 @@
import { notFound } from "next/navigation";
import { LayoutModuleGate } from "@/components/settings/layout-module-gate";
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
import { ModuleSettingsPageBody } from "@/components/settings/module-settings-pages";
import {
MODULE_SETTINGS_PAGE_IDS,
isModuleSettingsPageId,
moduleSettingsPageKeys,
moduleSettingsPageModule,
} from "@/components/settings/module-settings-page-ids";
import { SettingsShell } from "@/components/settings/settings-shell";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/**
* A module's own settings: `/settings/modules/<id>`.
*
* What a module needs beyond order and view (the labs biomarker catalog and
* on-device text recognition, the mood tags, the injection sites) lives here,
* under Settings > Modules, with a back link to the Modules list. Pre-rendered
* for the known ids; any other segment 404s at routing time.
*/
export const dynamicParams = false;
export function generateStaticParams() {
return MODULE_SETTINGS_PAGE_IDS.map((module) => ({ module }));
}
interface PageProps {
params: Promise<{ module: string }>;
}
export default async function SettingsModulePage({ params }: PageProps) {
const { module } = await params;
if (!isModuleSettingsPageId(module)) notFound();
const locale = await resolveServerLocale();
const { t } = getServerTranslator(locale);
const { titleKey, descriptionKey } = moduleSettingsPageKeys(module);
const headingId = `settings-section-modules-${module}-title`;
return (
<SettingsShell
active="modules"
heading={{
title: t(titleKey),
subtitle: t(descriptionKey),
headingId,
topSlot: (
<SettingsHubBackLink
href="/settings/modules"
labelKey="settings.sections.modules.backToHub"
/>
),
}}
>
{/* The cards here lived under Arrangement until v1.44 and keep its
posture inside somebody else's record, so the gate is asked about
`layout`, not about the Modules switches. */}
<RecordSettingsSectionGate section="layout">
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleKey={moduleSettingsPageModule(module)}>
<ModuleSettingsPageBody id={module} />
</LayoutModuleGate>
</section>
</RecordSettingsSectionGate>
</SettingsShell>
);
}