mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge branch 'feat/v144-settings' into release/v1.44.0
# Conflicts: # src/components/settings/privacy-section.tsx
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user