mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat: configure managed record settings
- Scope writable settings to the selected managed record\n- Render typed Guardian controls with separated audit attribution
This commit is contained in:
1 parent
b639b064f9
commit
e788675b82
19 files changed
+1879
-29
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
|
||||
parseManagedRecordSettingsPatch,
|
||||
} from "@/lib/record-settings/configuration";
|
||||
|
||||
describe("managed record settings configuration contract", () => {
|
||||
it("enumerates the only target-record fields each settings DTO may write", () => {
|
||||
expect(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST).toEqual({
|
||||
profile: [
|
||||
"displayName",
|
||||
"heightCm",
|
||||
"dateOfBirth",
|
||||
"gender",
|
||||
"locale",
|
||||
"timezone",
|
||||
"unitPreference",
|
||||
"timeFormat",
|
||||
"dateFormat",
|
||||
],
|
||||
modules: ["modulePreferences"],
|
||||
notifications: ["moodReminderEnabled", "notificationPreferences"],
|
||||
thresholds: ["overrides"],
|
||||
coach: ["disableCoach", "preferences"],
|
||||
insights: ["layout"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["profile", { email: "not-allowed@example.test" }],
|
||||
["modules", { role: "ADMIN" }],
|
||||
[
|
||||
"notifications",
|
||||
{
|
||||
notificationPreferences: { medication: { deliveryDefault: "client" } },
|
||||
},
|
||||
],
|
||||
["thresholds", { glucoseUnit: "imperial" }],
|
||||
["coach", { memories: [] }],
|
||||
["insights", { provider: "external" }],
|
||||
] as const)("rejects a disallowed %s field", (family, patch) => {
|
||||
expect(() => parseManagedRecordSettingsPatch(family, patch)).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"profile",
|
||||
{
|
||||
displayName: "Managed profile",
|
||||
heightCm: 120,
|
||||
dateOfBirth: "2016-04-03",
|
||||
gender: "OTHER",
|
||||
locale: "en",
|
||||
timezone: "Europe/Berlin",
|
||||
unitPreference: "metric",
|
||||
timeFormat: "H24",
|
||||
dateFormat: "DMY",
|
||||
},
|
||||
],
|
||||
["modules", { modulePreferences: { mood: false } }],
|
||||
[
|
||||
"notifications",
|
||||
{
|
||||
moodReminderEnabled: true,
|
||||
notificationPreferences: {
|
||||
medication: { lowStockRunwayDays: 7, reorderLeadDays: 10 },
|
||||
mood: { reminderHour: 22 },
|
||||
},
|
||||
},
|
||||
],
|
||||
["thresholds", { overrides: { WEIGHT: { min: 55, max: 80 } } }],
|
||||
[
|
||||
"coach",
|
||||
{
|
||||
disableCoach: false,
|
||||
preferences: {
|
||||
tone: "warm",
|
||||
verbosity: "default",
|
||||
excludeMetrics: [],
|
||||
showEvidenceByDefault: false,
|
||||
defaultWindow: "allTime",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"insights",
|
||||
{
|
||||
layout: {
|
||||
version: 2,
|
||||
tiles: [{ id: "weight", visible: true, order: 0 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const)("accepts a typed %s patch", (family, patch) => {
|
||||
expect(() => parseManagedRecordSettingsPatch(family, patch)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { toRecordSettingsDto } from "@/lib/record-settings/dto";
|
||||
import { resolveManagedIntegrationState } from "@/lib/record-settings/integrations";
|
||||
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response";
|
||||
import { recordSettingsKeys } from "@/lib/query-keys";
|
||||
|
||||
describe("record settings contract", () => {
|
||||
@@ -96,4 +97,19 @@ describe("record settings contract", () => {
|
||||
),
|
||||
).toBe("error_reauth");
|
||||
});
|
||||
|
||||
it("rejects a late response for another record in a second tab", () => {
|
||||
expect(() =>
|
||||
assertRecordSettingsResponseForRecord(
|
||||
{ recordId: "managed-record-b" },
|
||||
"managed-record-a",
|
||||
),
|
||||
).toThrow("did not match its key");
|
||||
expect(() =>
|
||||
assertRecordSettingsResponseForRecord(
|
||||
{ recordId: "managed-record-a" },
|
||||
"managed-record-a",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST } from "@/lib/record-settings";
|
||||
|
||||
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
const RECORD_SETTINGS_ROUTE_ROOT = fromRoot("src/app/api/record-settings");
|
||||
|
||||
describe("record settings route guard", () => {
|
||||
it("freezes the non-empty guardian-only route inventory", async () => {
|
||||
const discovered = (
|
||||
await readdir(RECORD_SETTINGS_ROUTE_ROOT, {
|
||||
recursive: true,
|
||||
})
|
||||
)
|
||||
.filter((entry) => entry.endsWith("route.ts"))
|
||||
.map((entry) => `app/api/record-settings/${entry}`)
|
||||
.sort();
|
||||
|
||||
expect(discovered).toHaveLength(3);
|
||||
expect(discovered).toEqual([
|
||||
"app/api/record-settings/[family]/route.ts",
|
||||
"app/api/record-settings/integrations/route.ts",
|
||||
"app/api/record-settings/route.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves every record-settings route through the Guardian fence", async () => {
|
||||
const routes = [
|
||||
"route.ts",
|
||||
"integrations/route.ts",
|
||||
"[family]/route.ts",
|
||||
] as const;
|
||||
const sources = await Promise.all(
|
||||
routes.map((route) =>
|
||||
readFile(resolve(RECORD_SETTINGS_ROUTE_ROOT, route), "utf8"),
|
||||
),
|
||||
);
|
||||
|
||||
for (const source of sources) {
|
||||
expect(source).toContain("requireGuardianAuth");
|
||||
expect(source).toContain("resolveGuardianRecordSettingsAccess");
|
||||
}
|
||||
});
|
||||
|
||||
it("makes the field-specific route exhaustive over the approved DTO families", async () => {
|
||||
const source = await readFile(
|
||||
resolve(RECORD_SETTINGS_ROUTE_ROOT, "[family]/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const families = Object.keys(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST);
|
||||
expect(families.length).toBeGreaterThan(0);
|
||||
for (const family of families) {
|
||||
expect(source).toContain(`case \"${family}\"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,29 +7,53 @@ const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
describe("managed record settings UI", () => {
|
||||
it("gates shared deep links and renders integration status without controls", async () => {
|
||||
const [statusView, settingsPage, sectionGate, ...localeBundles] =
|
||||
await Promise.all([
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-integration-status.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"),
|
||||
readFile(
|
||||
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
...["de", "en", "es", "fr", "it", "pl"].map((locale) =>
|
||||
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
|
||||
),
|
||||
]);
|
||||
const [
|
||||
statusView,
|
||||
managedSettingsView,
|
||||
settingsPage,
|
||||
layoutModulePage,
|
||||
sectionGate,
|
||||
...localeBundles
|
||||
] = await Promise.all([
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-integration-status.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-record-settings-section.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"),
|
||||
readFile(fromRoot("src/app/settings/layout/[module]/page.tsx"), "utf8"),
|
||||
readFile(
|
||||
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
...["de", "en", "es", "fr", "it", "pl"].map((locale) =>
|
||||
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(statusView).toContain("recordSettingsIntegrations");
|
||||
expect(statusView).toContain("/api/record-settings/integrations");
|
||||
expect(statusView).toContain("assertRecordSettingsResponseForRecord");
|
||||
expect(statusView).not.toContain("<Button");
|
||||
expect(statusView).not.toContain("<Link");
|
||||
expect(settingsPage).toContain("RecordSettingsSectionGate");
|
||||
expect(sectionGate).toContain("if (!inSharedRecord) return children");
|
||||
expect(sectionGate).toContain("ManagedIntegrationStatus");
|
||||
expect(sectionGate).toContain("ManagedRecordSettingsSection");
|
||||
expect(sectionGate).toContain("if (isLoading)");
|
||||
expect(layoutModulePage).toContain(
|
||||
'RecordSettingsSectionGate section="layout"',
|
||||
);
|
||||
expect(managedSettingsView).toContain(
|
||||
"safeParseManagedRecordSettingsPatch",
|
||||
);
|
||||
expect(managedSettingsView).toContain("/api/record-settings/${family}");
|
||||
expect(managedSettingsView).not.toContain("/api/auth/");
|
||||
expect(managedSettingsView).not.toContain("/api/user/");
|
||||
expect(managedSettingsView).not.toContain("<textarea");
|
||||
for (const bundle of localeBundles) {
|
||||
expect(bundle).toContain('"sharedRecord"');
|
||||
expect(bundle).toContain('"unavailableTitle"');
|
||||
|
||||
@@ -1750,6 +1750,8 @@ const GUARDIAN_ROUTES: Record<string, string> = {
|
||||
"The managed record's settings descriptor; it is refused for an adult record even with MANAGE.",
|
||||
"app/api/record-settings/integrations/route.ts":
|
||||
"Verified integration connection status for the managed record, without any provider control surface.",
|
||||
"app/api/record-settings/[family]/route.ts":
|
||||
"The closed, field-specific managed-record configuration DTOs; identity and credentials remain excluded.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1790,9 +1792,10 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* literals collides here by construction, so two people filling them at once
|
||||
* find out from the merge rather than from production. Import-status polling
|
||||
* adds two record entries and two MANAGE entries: 190 → 194. Managed-record
|
||||
* settings add two guardian-only entries: 194 → 196.
|
||||
* settings add two guardian-only entries: 194 → 196. Field-specific
|
||||
* managed-record configuration adds one more: 196 → 197.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 196;
|
||||
const FROZEN_ENTRY_COUNT = 197;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
apiHandler,
|
||||
requireGuardianAuth,
|
||||
SharingAccessDeniedError,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
invalidateUserInsightsLayout,
|
||||
invalidateUserProfile,
|
||||
} from "@/lib/cache/invalidate";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { type ThresholdOverridesJson } from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
isManagedRecordSettingsFamily,
|
||||
managedModulePreferencesFrom,
|
||||
parseManagedRecordSettingsPatch,
|
||||
resolveGuardianRecordSettingsAccess,
|
||||
} from "@/lib/record-settings";
|
||||
import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
} from "@/lib/insights-layout";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
import {
|
||||
parseNotificationPrefs,
|
||||
resolveNotificationPrefs,
|
||||
} from "@/lib/validations/notification-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ family: string }> };
|
||||
|
||||
async function resolveManagedRecordSettings(params: RouteParams["params"]) {
|
||||
const { family: rawFamily } = await params;
|
||||
if (!isManagedRecordSettingsFamily(rawFamily)) return null;
|
||||
|
||||
const context = await requireGuardianAuth();
|
||||
const access = resolveGuardianRecordSettingsAccess(context);
|
||||
if (!access) throw new SharingAccessDeniedError();
|
||||
|
||||
return { access, family: rawFamily };
|
||||
}
|
||||
|
||||
/** Each response names the target record and only one field-specific DTO. */
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const resolved = await resolveManagedRecordSettings(params);
|
||||
if (!resolved) return apiError("Settings family not found", 404);
|
||||
const { access, family } = resolved;
|
||||
|
||||
const record = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: {
|
||||
displayName: true,
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
locale: true,
|
||||
timezone: true,
|
||||
unitPreference: true,
|
||||
timeFormat: true,
|
||||
dateFormat: true,
|
||||
modulePreferencesJson: true,
|
||||
moodReminderEnabled: true,
|
||||
notificationPrefs: true,
|
||||
thresholdsJson: true,
|
||||
disableCoach: true,
|
||||
coachPrefsJson: true,
|
||||
insightsLayoutJson: true,
|
||||
},
|
||||
});
|
||||
if (!record) return apiError("Record not found", 404);
|
||||
|
||||
let settings: unknown;
|
||||
switch (family) {
|
||||
case "profile":
|
||||
settings = {
|
||||
displayName: record.displayName,
|
||||
heightCm: record.heightCm,
|
||||
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
gender: record.gender,
|
||||
locale: record.locale,
|
||||
timezone: record.timezone,
|
||||
unitPreference: record.unitPreference,
|
||||
timeFormat: record.timeFormat,
|
||||
dateFormat: record.dateFormat,
|
||||
};
|
||||
break;
|
||||
case "modules":
|
||||
settings = {
|
||||
modulePreferences: managedModulePreferencesFrom(
|
||||
record.modulePreferencesJson,
|
||||
),
|
||||
};
|
||||
break;
|
||||
case "notifications": {
|
||||
const preferences = parseNotificationPrefs(record.notificationPrefs);
|
||||
settings = {
|
||||
moodReminderEnabled: record.moodReminderEnabled,
|
||||
notificationPreferences: {
|
||||
medication: {
|
||||
lowStockRunwayDays: preferences.medication.lowStockRunwayDays,
|
||||
reorderLeadDays: preferences.medication.reorderLeadDays,
|
||||
},
|
||||
mood: { reminderHour: preferences.mood.reminderHour },
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "thresholds":
|
||||
settings = {
|
||||
overrides: (record.thresholdsJson ?? {}) as ThresholdOverridesJson,
|
||||
};
|
||||
break;
|
||||
case "coach": {
|
||||
const preferences = parseCoachPrefs(record.coachPrefsJson);
|
||||
settings = {
|
||||
disableCoach: record.disableCoach,
|
||||
preferences: {
|
||||
tone: preferences.tone,
|
||||
verbosity: preferences.verbosity,
|
||||
excludeMetrics: preferences.excludeMetrics,
|
||||
showEvidenceByDefault: preferences.showEvidenceByDefault,
|
||||
defaultWindow: preferences.defaultWindow,
|
||||
...(preferences.dataClusters !== undefined
|
||||
? { dataClusters: preferences.dataClusters }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "insights":
|
||||
settings = { layout: resolveInsightsLayout(record.insightsLayoutJson) };
|
||||
break;
|
||||
}
|
||||
|
||||
annotate({ action: { name: `record-settings.${family}.get` } });
|
||||
return apiSuccess({ recordId: access.recordId, family, settings });
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const resolved = await resolveManagedRecordSettings(params);
|
||||
if (!resolved) return apiError("Settings family not found", 404);
|
||||
const { access, family } = resolved;
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
let settings: unknown;
|
||||
let changed: readonly string[];
|
||||
|
||||
switch (family) {
|
||||
case "profile": {
|
||||
const patch = parseManagedRecordSettingsPatch("profile", body);
|
||||
const data = {
|
||||
...(patch.displayName !== undefined
|
||||
? { displayName: patch.displayName }
|
||||
: {}),
|
||||
...(patch.heightCm !== undefined
|
||||
? { heightCm: patch.heightCm }
|
||||
: {}),
|
||||
...(patch.dateOfBirth !== undefined
|
||||
? {
|
||||
dateOfBirth: patch.dateOfBirth
|
||||
? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
|
||||
: null,
|
||||
}
|
||||
: {}),
|
||||
...(patch.gender !== undefined ? { gender: patch.gender } : {}),
|
||||
...(patch.locale !== undefined ? { locale: patch.locale } : {}),
|
||||
...(patch.timezone !== undefined
|
||||
? { timezone: patch.timezone }
|
||||
: {}),
|
||||
...(patch.unitPreference !== undefined
|
||||
? { unitPreference: patch.unitPreference }
|
||||
: {}),
|
||||
...(patch.timeFormat !== undefined
|
||||
? { timeFormat: patch.timeFormat }
|
||||
: {}),
|
||||
...(patch.dateFormat !== undefined
|
||||
? { dateFormat: patch.dateFormat }
|
||||
: {}),
|
||||
};
|
||||
const record = await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data,
|
||||
select: {
|
||||
displayName: true,
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
locale: true,
|
||||
timezone: true,
|
||||
unitPreference: true,
|
||||
timeFormat: true,
|
||||
dateFormat: true,
|
||||
},
|
||||
});
|
||||
invalidateUserProfile(access.recordId);
|
||||
settings = {
|
||||
...record,
|
||||
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
};
|
||||
changed = Object.keys(patch);
|
||||
break;
|
||||
}
|
||||
case "modules": {
|
||||
const patch = parseManagedRecordSettingsPatch("modules", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { modulePreferencesJson: true },
|
||||
});
|
||||
const modulePreferences = {
|
||||
...managedModulePreferencesFrom(current?.modulePreferencesJson),
|
||||
...patch.modulePreferences,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { modulePreferencesJson: toJson(modulePreferences) },
|
||||
});
|
||||
settings = { modulePreferences };
|
||||
changed = Object.keys(patch.modulePreferences);
|
||||
break;
|
||||
}
|
||||
case "notifications": {
|
||||
const patch = parseManagedRecordSettingsPatch("notifications", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
const notificationPreferences = patch.notificationPreferences
|
||||
? resolveNotificationPrefs(
|
||||
current?.notificationPrefs,
|
||||
patch.notificationPreferences,
|
||||
)
|
||||
: undefined;
|
||||
const record = await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: {
|
||||
...(patch.moodReminderEnabled !== undefined
|
||||
? { moodReminderEnabled: patch.moodReminderEnabled }
|
||||
: {}),
|
||||
...(notificationPreferences !== undefined
|
||||
? { notificationPrefs: toJson(notificationPreferences) }
|
||||
: {}),
|
||||
},
|
||||
select: { moodReminderEnabled: true, notificationPrefs: true },
|
||||
});
|
||||
const preferences = parseNotificationPrefs(record.notificationPrefs);
|
||||
settings = {
|
||||
moodReminderEnabled: record.moodReminderEnabled,
|
||||
notificationPreferences: {
|
||||
medication: {
|
||||
lowStockRunwayDays: preferences.medication.lowStockRunwayDays,
|
||||
reorderLeadDays: preferences.medication.reorderLeadDays,
|
||||
},
|
||||
mood: { reminderHour: preferences.mood.reminderHour },
|
||||
},
|
||||
};
|
||||
changed = Object.keys(patch);
|
||||
break;
|
||||
}
|
||||
case "thresholds": {
|
||||
const patch = parseManagedRecordSettingsPatch("thresholds", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { thresholdsJson: true },
|
||||
});
|
||||
const overrides = {
|
||||
...((current?.thresholdsJson ?? {}) as ThresholdOverridesJson),
|
||||
...patch.overrides,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { thresholdsJson: toJson(overrides) },
|
||||
});
|
||||
settings = { overrides };
|
||||
changed = Object.keys(patch.overrides);
|
||||
break;
|
||||
}
|
||||
case "coach": {
|
||||
const patch = parseManagedRecordSettingsPatch("coach", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { coachPrefsJson: true },
|
||||
});
|
||||
const nextPreferences = patch.preferences
|
||||
? {
|
||||
...parseCoachPrefs(current?.coachPrefsJson),
|
||||
...patch.preferences,
|
||||
}
|
||||
: undefined;
|
||||
const record = await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: {
|
||||
...(patch.disableCoach !== undefined
|
||||
? { disableCoach: patch.disableCoach }
|
||||
: {}),
|
||||
...(nextPreferences !== undefined
|
||||
? {
|
||||
coachPrefsJson: toJson(nextPreferences),
|
||||
insightsExcludeMetrics: nextPreferences.excludeMetrics,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
select: { disableCoach: true, coachPrefsJson: true },
|
||||
});
|
||||
const preferences = parseCoachPrefs(record.coachPrefsJson);
|
||||
settings = {
|
||||
disableCoach: record.disableCoach,
|
||||
preferences: {
|
||||
tone: preferences.tone,
|
||||
verbosity: preferences.verbosity,
|
||||
excludeMetrics: preferences.excludeMetrics,
|
||||
showEvidenceByDefault: preferences.showEvidenceByDefault,
|
||||
defaultWindow: preferences.defaultWindow,
|
||||
...(preferences.dataClusters !== undefined
|
||||
? { dataClusters: preferences.dataClusters }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
changed = Object.keys(patch);
|
||||
break;
|
||||
}
|
||||
case "insights": {
|
||||
const patch = parseManagedRecordSettingsPatch("insights", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { insightsLayoutJson: true },
|
||||
});
|
||||
const layout = serializeInsightsLayout(
|
||||
patch.layout,
|
||||
resolveInsightsLayout(current?.insightsLayoutJson),
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { insightsLayoutJson: toJson(layout) },
|
||||
});
|
||||
invalidateUserInsightsLayout(access.recordId);
|
||||
settings = { layout };
|
||||
changed = ["layout"];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog(`record-settings.${family}.update`, {
|
||||
userId: access.recordId,
|
||||
actorUserId: access.actorId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { changed },
|
||||
});
|
||||
annotate({
|
||||
action: { name: `record-settings.${family}.update` },
|
||||
meta: { recordId: access.recordId, changed },
|
||||
});
|
||||
return apiSuccess({ recordId: access.recordId, family, settings });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "issues" in error) {
|
||||
return returnAllZodIssues(error as never, 422);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { LayoutModuleGate } from "@/components/settings/layout-module-gate";
|
||||
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
|
||||
import {
|
||||
LAYOUT_GROUPS,
|
||||
LAYOUT_GROUP_IDS,
|
||||
@@ -68,11 +69,13 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) {
|
||||
headingId,
|
||||
}}
|
||||
>
|
||||
<section aria-labelledby={headingId} className="space-y-6">
|
||||
<LayoutModuleGate moduleGate={group.moduleGate}>
|
||||
<Body />
|
||||
</LayoutModuleGate>
|
||||
</section>
|
||||
<RecordSettingsSectionGate section="layout">
|
||||
<section aria-labelledby={headingId} className="space-y-6">
|
||||
<LayoutModuleGate moduleGate={group.moduleGate}>
|
||||
<Body />
|
||||
</LayoutModuleGate>
|
||||
</section>
|
||||
</RecordSettingsSectionGate>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -126,6 +126,16 @@ const OWNER_WRITE: AccountAccessEntry = {
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
};
|
||||
const MANAGED_GUARDIAN: AccountAccessEntry = {
|
||||
accountId: "managed-record",
|
||||
username: "managed-record",
|
||||
displayName: "Managed profile",
|
||||
access: "write" as const,
|
||||
level: "manage" as const,
|
||||
sections: null,
|
||||
recordKind: "managed" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
function render(access: AccountAccess, pathname = "/"): string {
|
||||
mockAccessRef.value = access;
|
||||
@@ -199,4 +209,11 @@ describe("<AuthShell> — the owner-only doors", () => {
|
||||
expect(html).not.toContain(slot);
|
||||
}
|
||||
});
|
||||
|
||||
it("admits a switched Guardian to the record-aware Settings gate", () => {
|
||||
const html = render(SWITCHED(MANAGED_GUARDIAN), "/settings/integrations");
|
||||
|
||||
expect(html).toContain("sentinel-page");
|
||||
expect(html).not.toContain("sentinel-unavailable");
|
||||
});
|
||||
});
|
||||
@@ -78,14 +78,21 @@ export function AuthShell({
|
||||
// decides what to mount and the surfaces beneath it decide what to offer,
|
||||
// and the two disagreeing is the whole class of defect this hook exists to
|
||||
// close.
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const { inSharedRecord, recordKind } = useRecordCapabilities();
|
||||
// A managed profile has a deliberately small Settings surface of its own.
|
||||
// Let that route family reach its record-aware gate; adult shared records
|
||||
// stay in the generic refusal branch even at MANAGE.
|
||||
const isManagedRecordSettingsPath =
|
||||
recordKind === "managed" && pathname.startsWith("/settings");
|
||||
// v1.36.0 — a page reached while acting on somebody else's record that
|
||||
// sharing does not cover. The nav already drops these entries, so arriving
|
||||
// here means a bookmark or a browser back button; the panel below explains
|
||||
// it instead of letting the page paint a row of 403 error cards. Paint only
|
||||
// — every route behind these pages refuses on its own.
|
||||
const outsideSharedRecord =
|
||||
inSharedRecord && !isDestinationInSharedRecord(pathname);
|
||||
inSharedRecord &&
|
||||
!isDestinationInSharedRecord(pathname) &&
|
||||
!isManagedRecordSettingsPath;
|
||||
const isOnboardingPage = pathname === "/onboarding";
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
@@ -222,7 +229,7 @@ export function AuthShell({
|
||||
// flight: the role is unknown until the payload lands, and mounting
|
||||
// `/admin/*` children early would fire admin queries that 403 for a
|
||||
// non-admin before the redirect effect can move them away.
|
||||
if (isLoading && isAdminPage) {
|
||||
if (isLoading && (isAdminPage || pathname.startsWith("/settings"))) {
|
||||
return (
|
||||
<div className="flex h-dvh items-center justify-center" role="status">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
|
||||
|
||||
const render = (
|
||||
family: Parameters<typeof ManagedRecordSettingsForm>[0]["family"],
|
||||
settings: Record<string, unknown>,
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
<ManagedRecordSettingsForm
|
||||
disabled={false}
|
||||
family={family}
|
||||
onSave={vi.fn()}
|
||||
saveLabel="Save"
|
||||
settings={settings}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("managed record settings forms", () => {
|
||||
it.each([
|
||||
["profile", { timezone: "Europe/Berlin" }, "managed-display-name"],
|
||||
["modules", { modulePreferences: { mood: true } }, "mood"],
|
||||
[
|
||||
"notifications",
|
||||
{
|
||||
moodReminderEnabled: true,
|
||||
notificationPreferences: {
|
||||
medication: { lowStockRunwayDays: 7, reorderLeadDays: 10 },
|
||||
mood: { reminderHour: 22 },
|
||||
},
|
||||
},
|
||||
"managed-reminder-hour",
|
||||
],
|
||||
[
|
||||
"thresholds",
|
||||
{ overrides: { WEIGHT: { min: 55, max: 80 } } },
|
||||
"managed-threshold-metric",
|
||||
],
|
||||
[
|
||||
"coach",
|
||||
{ disableCoach: false, preferences: { tone: "warm" } },
|
||||
"managed-coach-tone",
|
||||
],
|
||||
[
|
||||
"insights",
|
||||
{
|
||||
layout: {
|
||||
version: 2,
|
||||
tiles: [{ id: "weight", visible: true, order: 0 }],
|
||||
},
|
||||
},
|
||||
"tile-order-weight",
|
||||
],
|
||||
] as const)(
|
||||
"renders labeled %s controls without an internal JSON editor",
|
||||
(family, settings, control) => {
|
||||
const html = render(family, settings);
|
||||
|
||||
expect(html).toContain(control);
|
||||
expect(html).toContain("Save");
|
||||
expect(html).not.toContain("<textarea");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const authRef: {
|
||||
isLoading: boolean;
|
||||
active: {
|
||||
accountId: string;
|
||||
recordKind: "managed" | "shared";
|
||||
level: "manage";
|
||||
sections: null;
|
||||
canWrite: true;
|
||||
} | null;
|
||||
} = {
|
||||
isLoading: true,
|
||||
active: null,
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isLoading: authRef.isLoading,
|
||||
user: authRef.active
|
||||
? {
|
||||
accountAccess: {
|
||||
active: authRef.active,
|
||||
accounts: [authRef.active],
|
||||
canSwitch: true,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../managed-record-settings-section", () => ({
|
||||
ManagedRecordSettingsSection: () => <div data-slot="managed-settings" />,
|
||||
}));
|
||||
vi.mock("../managed-integration-status", () => ({
|
||||
ManagedIntegrationStatus: () => <div data-slot="managed-integrations" />,
|
||||
}));
|
||||
|
||||
import { RecordSettingsSectionGate } from "../record-settings-section-gate";
|
||||
|
||||
function render(section: "modules" | "integrations") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<RecordSettingsSectionGate section={section}>
|
||||
<div data-slot="actor-settings" />
|
||||
</RecordSettingsSectionGate>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RecordSettingsSectionGate", () => {
|
||||
it("does not mount an actor Settings component while auth is unresolved", () => {
|
||||
authRef.isLoading = true;
|
||||
authRef.active = null;
|
||||
|
||||
const html = render("modules");
|
||||
|
||||
expect(html).not.toContain("actor-settings");
|
||||
expect(html).toContain('role="status"');
|
||||
});
|
||||
|
||||
it("uses the record-scoped section under a switched Guardian", () => {
|
||||
authRef.isLoading = false;
|
||||
authRef.active = {
|
||||
accountId: "managed-record",
|
||||
recordKind: "managed",
|
||||
level: "manage",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const html = render("modules");
|
||||
|
||||
expect(html).toContain("managed-settings");
|
||||
expect(html).not.toContain("actor-settings");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings";
|
||||
|
||||
type ManagedIntegrationState =
|
||||
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
@@ -63,10 +64,13 @@ export function ManagedIntegrationStatus() {
|
||||
const recordId = activeRecord?.accountId ?? null;
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.recordSettingsIntegrations(recordId ?? ""),
|
||||
queryFn: () =>
|
||||
apiGet<ManagedIntegrationStatusResponse>(
|
||||
queryFn: async () => {
|
||||
const response = await apiGet<ManagedIntegrationStatusResponse>(
|
||||
"/api/record-settings/integrations",
|
||||
),
|
||||
);
|
||||
assertRecordSettingsResponseForRecord(response, recordId);
|
||||
return response;
|
||||
},
|
||||
enabled: activeRecord?.recordKind === "managed" && recordId !== null,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
assertRecordSettingsResponseForRecord,
|
||||
safeParseManagedRecordSettingsPatch,
|
||||
type ManagedRecordSettingsFamily,
|
||||
} from "@/lib/record-settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { SettingsCardHeader } from "./_card-header";
|
||||
import { SettingsCard } from "./settings-card";
|
||||
|
||||
interface ManagedRecordSettingsResponse {
|
||||
recordId: string;
|
||||
family: ManagedRecordSettingsFamily;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type ManagedSettingsFamily = Exclude<
|
||||
ManagedRecordSettingsFamily,
|
||||
"integrations"
|
||||
>;
|
||||
type SettingsFormProps = {
|
||||
settings: Record<string, unknown>;
|
||||
disabled: boolean;
|
||||
onSave: (patch: unknown) => void;
|
||||
saveLabel: string;
|
||||
};
|
||||
|
||||
const COACH_EXCLUDE_METRICS = [
|
||||
"bp",
|
||||
"weight",
|
||||
"pulse",
|
||||
"mood",
|
||||
"compliance",
|
||||
"hrv",
|
||||
"sleep",
|
||||
"resting_hr",
|
||||
"steps",
|
||||
"medications",
|
||||
"anthropometrics",
|
||||
] as const;
|
||||
const COACH_DATA_CLUSTERS = [
|
||||
"cardio",
|
||||
"body",
|
||||
"activity",
|
||||
"workouts",
|
||||
"sleep",
|
||||
"mood",
|
||||
"glucose",
|
||||
"medication",
|
||||
"mobility",
|
||||
"environment",
|
||||
] as const;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function formNumber(form: FormData, name: string, fallback: number): number {
|
||||
const value = Number(form.get(name));
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function SaveButton({ disabled, label }: { disabled: boolean; label: string }) {
|
||||
return (
|
||||
<Button disabled={disabled} type="submit">
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSettingsForm({
|
||||
settings,
|
||||
disabled,
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const displayName = asString(form.get("displayName")).trim();
|
||||
const heightValue = asString(form.get("heightCm")).trim();
|
||||
onSave({
|
||||
displayName: displayName || null,
|
||||
heightCm: heightValue === "" ? null : Number(heightValue),
|
||||
dateOfBirth: asString(form.get("dateOfBirth")) || null,
|
||||
gender: asString(form.get("gender")) || null,
|
||||
locale: asString(form.get("locale")) || null,
|
||||
timezone: asString(form.get("timezone")),
|
||||
unitPreference: asString(form.get("unitPreference")),
|
||||
timeFormat: asString(form.get("timeFormat")),
|
||||
dateFormat: asString(form.get("dateFormat")),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="mt-4 grid gap-4 sm:grid-cols-2" onSubmit={submit}>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-display-name">
|
||||
Display name
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.displayName)}
|
||||
disabled={disabled}
|
||||
id="managed-display-name"
|
||||
name="displayName"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-height">
|
||||
Height (cm)
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={
|
||||
settings.heightCm === null ? "" : String(settings.heightCm ?? "")
|
||||
}
|
||||
disabled={disabled}
|
||||
id="managed-height"
|
||||
max="300"
|
||||
min="30"
|
||||
name="heightCm"
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-date-of-birth">
|
||||
Date of birth
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.dateOfBirth)}
|
||||
disabled={disabled}
|
||||
id="managed-date-of-birth"
|
||||
name="dateOfBirth"
|
||||
type="date"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-gender">
|
||||
Gender
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.gender)}
|
||||
disabled={disabled}
|
||||
id="managed-gender"
|
||||
name="gender"
|
||||
>
|
||||
<option value="">Not specified</option>
|
||||
<option value="MALE">Male</option>
|
||||
<option value="FEMALE">Female</option>
|
||||
<option value="OTHER">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-locale">
|
||||
Locale
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.locale)}
|
||||
disabled={disabled}
|
||||
id="managed-locale"
|
||||
name="locale"
|
||||
>
|
||||
<option value="">System default</option>
|
||||
{["de", "en", "es", "fr", "it", "pl"].map((locale) => (
|
||||
<option key={locale} value={locale}>
|
||||
{locale}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-timezone">
|
||||
Timezone
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.timezone, "Europe/Berlin")}
|
||||
disabled={disabled}
|
||||
id="managed-timezone"
|
||||
name="timezone"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-units">
|
||||
Units
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.unitPreference, "metric")}
|
||||
disabled={disabled}
|
||||
id="managed-units"
|
||||
name="unitPreference"
|
||||
>
|
||||
<option value="metric">Metric</option>
|
||||
<option value="imperial">Imperial</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-time-format">
|
||||
Time format
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.timeFormat, "AUTO")}
|
||||
disabled={disabled}
|
||||
id="managed-time-format"
|
||||
name="timeFormat"
|
||||
>
|
||||
<option value="AUTO">Automatic</option>
|
||||
<option value="H12">12-hour</option>
|
||||
<option value="H24">24-hour</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-date-format">
|
||||
Date format
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.dateFormat, "AUTO")}
|
||||
disabled={disabled}
|
||||
id="managed-date-format"
|
||||
name="dateFormat"
|
||||
>
|
||||
<option value="AUTO">Automatic</option>
|
||||
<option value="DMY">Day / month / year</option>
|
||||
<option value="MDY">Month / day / year</option>
|
||||
<option value="YMD">Year / month / day</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="sm:col-span-2">
|
||||
<SaveButton disabled={disabled} label={saveLabel} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ModulesSettingsForm({
|
||||
settings,
|
||||
disabled,
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const preferences = asRecord(settings.modulePreferences);
|
||||
const entries = Object.entries(preferences).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
onSave({
|
||||
modulePreferences: Object.fromEntries(
|
||||
entries.map(([key]) => [key, form.get(key) === "on"]),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="mt-4 space-y-3" onSubmit={submit}>
|
||||
{entries.map(([key, enabled]) => (
|
||||
<label className="flex items-center gap-3 text-sm" key={key}>
|
||||
<input
|
||||
defaultChecked={enabled === true}
|
||||
disabled={disabled}
|
||||
name={key}
|
||||
type="checkbox"
|
||||
/>
|
||||
{key}
|
||||
</label>
|
||||
))}
|
||||
<SaveButton disabled={disabled} label={saveLabel} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsSettingsForm({
|
||||
settings,
|
||||
disabled,
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const preferences = asRecord(settings.notificationPreferences);
|
||||
const medication = asRecord(preferences.medication);
|
||||
const mood = asRecord(preferences.mood);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
onSave({
|
||||
moodReminderEnabled: form.get("moodReminderEnabled") === "on",
|
||||
notificationPreferences: {
|
||||
medication: {
|
||||
lowStockRunwayDays:
|
||||
form.get("lowStockRunwayDays") === ""
|
||||
? null
|
||||
: formNumber(form, "lowStockRunwayDays", 7),
|
||||
reorderLeadDays: formNumber(form, "reorderLeadDays", 10),
|
||||
},
|
||||
mood: { reminderHour: formNumber(form, "reminderHour", 22) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="mt-4 grid gap-4 sm:grid-cols-2" onSubmit={submit}>
|
||||
<label className="flex items-center gap-3 text-sm sm:col-span-2">
|
||||
<input
|
||||
defaultChecked={settings.moodReminderEnabled === true}
|
||||
disabled={disabled}
|
||||
name="moodReminderEnabled"
|
||||
type="checkbox"
|
||||
/>
|
||||
Enable mood reminder
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-reminder-hour">
|
||||
Mood reminder hour
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asNumber(mood.reminderHour, 22)}
|
||||
disabled={disabled}
|
||||
id="managed-reminder-hour"
|
||||
max="23"
|
||||
min="0"
|
||||
name="reminderHour"
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-low-stock">
|
||||
Low-stock runway days
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={
|
||||
medication.lowStockRunwayDays === null
|
||||
? ""
|
||||
: asNumber(medication.lowStockRunwayDays, 7)
|
||||
}
|
||||
disabled={disabled}
|
||||
id="managed-low-stock"
|
||||
max="60"
|
||||
min="1"
|
||||
name="lowStockRunwayDays"
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-reorder-lead">
|
||||
Reorder lead days
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asNumber(medication.reorderLeadDays, 10)}
|
||||
disabled={disabled}
|
||||
id="managed-reorder-lead"
|
||||
max="60"
|
||||
min="0"
|
||||
name="reorderLeadDays"
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
<div className="sm:col-span-2">
|
||||
<SaveButton disabled={disabled} label={saveLabel} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ThresholdsSettingsForm({
|
||||
settings,
|
||||
disabled,
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const overrides = asRecord(settings.overrides);
|
||||
const metrics = Object.keys(overrides);
|
||||
const metric = metrics[0] ?? "WEIGHT";
|
||||
const firstRange = asRecord(overrides[metric]);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const selectedMetric = asString(form.get("metric"), metric);
|
||||
onSave({
|
||||
overrides: {
|
||||
[selectedMetric]: {
|
||||
min: formNumber(form, "minimum", asNumber(firstRange.min, 0)),
|
||||
max: formNumber(form, "maximum", asNumber(firstRange.max, 0)),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="mt-4 grid gap-4 sm:grid-cols-3" onSubmit={submit}>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-metric">
|
||||
Metric
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={metric}
|
||||
disabled={disabled}
|
||||
id="managed-threshold-metric"
|
||||
name="metric"
|
||||
>
|
||||
{[...new Set([metric, ...metrics])].map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{entry}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-minimum">
|
||||
Minimum
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asNumber(firstRange.min, 0)}
|
||||
disabled={disabled}
|
||||
id="managed-threshold-minimum"
|
||||
name="minimum"
|
||||
required
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-maximum">
|
||||
Maximum
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asNumber(firstRange.max, 0)}
|
||||
disabled={disabled}
|
||||
id="managed-threshold-maximum"
|
||||
name="maximum"
|
||||
required
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
<div className="sm:col-span-3">
|
||||
<SaveButton disabled={disabled} label={saveLabel} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function CoachSettingsForm({
|
||||
settings,
|
||||
disabled,
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const preferences = asRecord(settings.preferences);
|
||||
const excluded = new Set(asStringArray(preferences.excludeMetrics));
|
||||
const dataClusters = new Set(asStringArray(preferences.dataClusters));
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
onSave({
|
||||
disableCoach: form.get("disableCoach") === "on",
|
||||
preferences: {
|
||||
tone: asString(form.get("tone"), "warm"),
|
||||
verbosity: asString(form.get("verbosity"), "default"),
|
||||
excludeMetrics: COACH_EXCLUDE_METRICS.filter(
|
||||
(metric) => form.get(`exclude-${metric}`) === "on",
|
||||
),
|
||||
showEvidenceByDefault: form.get("showEvidenceByDefault") === "on",
|
||||
defaultWindow: asString(form.get("defaultWindow"), "allTime"),
|
||||
...(form.get("useCustomClusters") === "on"
|
||||
? {
|
||||
dataClusters: COACH_DATA_CLUSTERS.filter(
|
||||
(cluster) => form.get(`cluster-${cluster}`) === "on",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="mt-4 space-y-4" onSubmit={submit}>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<input
|
||||
defaultChecked={settings.disableCoach === true}
|
||||
disabled={disabled}
|
||||
name="disableCoach"
|
||||
type="checkbox"
|
||||
/>
|
||||
Disable Coach
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-coach-tone">
|
||||
Tone
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(preferences.tone, "warm")}
|
||||
disabled={disabled}
|
||||
id="managed-coach-tone"
|
||||
name="tone"
|
||||
>
|
||||
<option value="warm">Warm</option>
|
||||
<option value="neutral">Neutral</option>
|
||||
<option value="concise">Concise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-coach-verbosity">
|
||||
Verbosity
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(preferences.verbosity, "default")}
|
||||
disabled={disabled}
|
||||
id="managed-coach-verbosity"
|
||||
name="verbosity"
|
||||
>
|
||||
<option value="brief">Brief</option>
|
||||
<option value="default">Default</option>
|
||||
<option value="detailed">Detailed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-coach-window">
|
||||
Default window
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(preferences.defaultWindow, "allTime")}
|
||||
disabled={disabled}
|
||||
id="managed-coach-window"
|
||||
name="defaultWindow"
|
||||
>
|
||||
<option value="last7days">Last 7 days</option>
|
||||
<option value="last30days">Last 30 days</option>
|
||||
<option value="last90days">Last 90 days</option>
|
||||
<option value="allTime">All time</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<input
|
||||
defaultChecked={preferences.showEvidenceByDefault === true}
|
||||
disabled={disabled}
|
||||
name="showEvidenceByDefault"
|
||||
type="checkbox"
|
||||
/>
|
||||
Show evidence by default
|
||||
</label>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Excluded metrics</legend>
|
||||
{COACH_EXCLUDE_METRICS.map((metric) => (
|
||||
<label
|
||||
className="mr-4 inline-flex items-center gap-2 text-sm"
|
||||
key={metric}
|
||||
>
|
||||
<input
|
||||
defaultChecked={excluded.has(metric)}
|
||||
disabled={disabled}
|
||||
name={`exclude-${metric}`}
|
||||
type="checkbox"
|
||||
/>
|
||||
{metric}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Data clusters</legend>
|
||||
<label className="mb-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
defaultChecked={Array.isArray(preferences.dataClusters)}
|
||||
disabled={disabled}
|
||||
name="useCustomClusters"
|
||||
type="checkbox"
|
||||
/>
|
||||
Use a custom cluster selection
|
||||
</label>
|
||||
{COACH_DATA_CLUSTERS.map((cluster) => (
|
||||
<label
|
||||
className="mr-4 inline-flex items-center gap-2 text-sm"
|
||||
key={cluster}
|
||||
>
|
||||
<input
|
||||
defaultChecked={dataClusters.has(cluster)}
|
||||
disabled={disabled}
|
||||
name={`cluster-${cluster}`}
|
||||
type="checkbox"
|
||||
/>
|
||||
{cluster}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<SaveButton disabled={disabled} label={saveLabel} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type LayoutItem = { id: string; visible: boolean; order: number };
|
||||
|
||||
function layoutItems(value: unknown): LayoutItem[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry) => {
|
||||
const item = asRecord(entry);
|
||||
const id = asString(item.id);
|
||||
return id
|
||||
? [
|
||||
{
|
||||
id,
|
||||
visible: item.visible === true,
|
||||
order: asNumber(item.order, 0),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function InsightsSettingsForm({
|
||||
settings,
|
||||
disabled,
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const layout = asRecord(settings.layout);
|
||||
const sections = layoutItems(layout.sections);
|
||||
const tiles = layoutItems(layout.tiles);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const toPatch = (kind: "section" | "tile", item: LayoutItem) => ({
|
||||
id: item.id,
|
||||
visible: form.get(`${kind}-visible-${item.id}`) === "on",
|
||||
order: formNumber(form, `${kind}-order-${item.id}`, item.order),
|
||||
});
|
||||
onSave({
|
||||
layout: {
|
||||
version: asNumber(layout.version, 2),
|
||||
sections: sections.map((section) => toPatch("section", section)),
|
||||
tiles: tiles.map((tile) => toPatch("tile", tile)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const renderItems = (kind: "section" | "tile", items: LayoutItem[]) => (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{kind === "section" ? "Overview sections" : "Navigation tiles"}
|
||||
</legend>
|
||||
{items.map((item) => (
|
||||
<div className="flex items-center gap-3 text-sm" key={item.id}>
|
||||
<label className="flex flex-1 items-center gap-2">
|
||||
<input
|
||||
defaultChecked={item.visible}
|
||||
disabled={disabled}
|
||||
name={`${kind}-visible-${item.id}`}
|
||||
type="checkbox"
|
||||
/>
|
||||
{item.id}
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-2"
|
||||
htmlFor={`${kind}-order-${item.id}`}
|
||||
>
|
||||
Order
|
||||
<input
|
||||
className="border-input bg-background w-16 rounded-md border px-2 py-1"
|
||||
defaultValue={item.order}
|
||||
disabled={disabled}
|
||||
id={`${kind}-order-${item.id}`}
|
||||
min="0"
|
||||
name={`${kind}-order-${item.id}`}
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
return (
|
||||
<form className="mt-4 space-y-5" onSubmit={submit}>
|
||||
{renderItems("section", sections)}
|
||||
{renderItems("tile", tiles)}
|
||||
<SaveButton disabled={disabled} label={saveLabel} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManagedRecordSettingsForm({
|
||||
family,
|
||||
...props
|
||||
}: SettingsFormProps & { family: ManagedSettingsFamily }) {
|
||||
switch (family) {
|
||||
case "profile":
|
||||
return <ProfileSettingsForm {...props} />;
|
||||
case "modules":
|
||||
return <ModulesSettingsForm {...props} />;
|
||||
case "notifications":
|
||||
return <NotificationsSettingsForm {...props} />;
|
||||
case "thresholds":
|
||||
return <ThresholdsSettingsForm {...props} />;
|
||||
case "coach":
|
||||
return <CoachSettingsForm {...props} />;
|
||||
case "insights":
|
||||
return <InsightsSettingsForm {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Managed settings use typed target-record controls only. The actor's
|
||||
* Settings components never mount here, so their actor-scoped queries and
|
||||
* mutations cannot fire while a Guardian administers a profile.
|
||||
*/
|
||||
export function ManagedRecordSettingsSection({
|
||||
family,
|
||||
}: {
|
||||
family: ManagedSettingsFamily;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const recordId = user?.accountAccess?.active?.accountId ?? null;
|
||||
const [validationFailed, setValidationFailed] = useState(false);
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.recordSettingsFamily(recordId ?? "", family),
|
||||
enabled: recordId !== null,
|
||||
queryFn: async () => {
|
||||
const response = await apiGet<ManagedRecordSettingsResponse>(
|
||||
`/api/record-settings/${family}`,
|
||||
);
|
||||
assertRecordSettingsResponseForRecord(response, recordId);
|
||||
if (response.family !== family) {
|
||||
throw new Error(
|
||||
"Managed record settings response did not match its key",
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
const save = useMutation({
|
||||
mutationFn: async (patch: unknown) => {
|
||||
const response = await apiPatch<ManagedRecordSettingsResponse>(
|
||||
`/api/record-settings/${family}`,
|
||||
patch,
|
||||
);
|
||||
assertRecordSettingsResponseForRecord(response, recordId);
|
||||
if (response.family !== family) {
|
||||
throw new Error(
|
||||
"Managed record settings response did not match its key",
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
if (recordId === null) return;
|
||||
queryClient.setQueryData(
|
||||
queryKeys.recordSettingsFamily(recordId, family),
|
||||
response,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (query.isError) {
|
||||
return <QueryErrorCard onRetry={() => void query.refetch()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard aria-busy={query.isLoading}>
|
||||
<SettingsCardHeader
|
||||
icon={Settings}
|
||||
title={t(`settings.sections.${family}.title`)}
|
||||
/>
|
||||
{query.data ? (
|
||||
<>
|
||||
<ManagedRecordSettingsForm
|
||||
disabled={save.isPending}
|
||||
family={family}
|
||||
onSave={(patch) => {
|
||||
const parsed = safeParseManagedRecordSettingsPatch(family, patch);
|
||||
if (!parsed.success) {
|
||||
setValidationFailed(true);
|
||||
return;
|
||||
}
|
||||
setValidationFailed(false);
|
||||
save.mutate(parsed.data);
|
||||
}}
|
||||
saveLabel={t("common.save")}
|
||||
settings={query.data.settings}
|
||||
/>
|
||||
{save.isError || validationFailed ? (
|
||||
<p className="text-destructive mt-3 text-sm" role="alert">
|
||||
{t("common.networkError")}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { CircleSlash } from "lucide-react";
|
||||
import { CircleSlash, Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ManagedIntegrationStatus } from "@/components/settings/managed-integration-status";
|
||||
import { ManagedRecordSettingsSection } from "@/components/settings/managed-record-settings-section";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
const MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION = {
|
||||
account: "profile",
|
||||
modules: "modules",
|
||||
notifications: "notifications",
|
||||
insights: "insights",
|
||||
thresholds: "thresholds",
|
||||
coach: "coach",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Stops actor-scoped Settings components from mounting while the browser is
|
||||
* switched into another record. The only managed integration surface is its
|
||||
@@ -23,9 +34,22 @@ export function RecordSettingsSectionGate({
|
||||
section: SettingsSectionSlug;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { isLoading } = useAuth();
|
||||
const { inSharedRecord, recordKind } = useRecordCapabilities();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// A shared session must never begin by mounting the actor's Settings
|
||||
// component and withdraw it when `/api/auth/me` resolves. The shell holds
|
||||
// Settings during auth resolution too; this branch keeps direct route use
|
||||
// equally safe when the gate is rendered independently in a test or story.
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsCard aria-busy="true" role="status">
|
||||
<SettingsCardHeader icon={Loader2} title={t("nav.loadingScreen")} />
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!inSharedRecord) return children;
|
||||
|
||||
const destination = classifySettingsDestination(section);
|
||||
@@ -37,6 +61,18 @@ export function RecordSettingsSectionGate({
|
||||
return <ManagedIntegrationStatus />;
|
||||
}
|
||||
|
||||
if (
|
||||
recordKind === "managed" &&
|
||||
destination.kind === "managed-guardian" &&
|
||||
section in MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION
|
||||
) {
|
||||
const family =
|
||||
MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION[
|
||||
section as keyof typeof MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION
|
||||
];
|
||||
return <ManagedRecordSettingsSection family={family} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard aria-labelledby="shared-record-settings-unavailable-title">
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -12,6 +12,8 @@ export const recordSettingsKeys = {
|
||||
["record-settings", recordId, "integrations"] as const,
|
||||
profile: (recordId: string) =>
|
||||
["record-settings", recordId, "profile"] as const,
|
||||
family: (recordId: string, family: string) =>
|
||||
["record-settings", recordId, family] as const,
|
||||
/** Named aliases stay unambiguous when assembled into `queryKeys`. */
|
||||
recordSettingsDetail: (recordId: string) =>
|
||||
["record-settings", recordId, "detail"] as const,
|
||||
@@ -19,4 +21,6 @@ export const recordSettingsKeys = {
|
||||
["record-settings", recordId, "integrations"] as const,
|
||||
recordSettingsProfile: (recordId: string) =>
|
||||
["record-settings", recordId, "profile"] as const,
|
||||
recordSettingsFamily: (recordId: string, family: string) =>
|
||||
["record-settings", recordId, family] as const,
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
ACCEPTED_INSIGHTS_TILE_IDS,
|
||||
INSIGHTS_SECTION_IDS,
|
||||
} from "@/lib/insights-layout";
|
||||
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
|
||||
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
|
||||
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
|
||||
|
||||
/**
|
||||
* The target-record configuration boundary. These literals are both the DTO
|
||||
* contract and the reviewable answer to which fields a Guardian may change.
|
||||
* Identity, credentials, delivery channels, memories, provider connections,
|
||||
* and every future field fail closed because no family accepts them.
|
||||
*/
|
||||
export const MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST = {
|
||||
profile: [
|
||||
"displayName",
|
||||
"heightCm",
|
||||
"dateOfBirth",
|
||||
"gender",
|
||||
"locale",
|
||||
"timezone",
|
||||
"unitPreference",
|
||||
"timeFormat",
|
||||
"dateFormat",
|
||||
],
|
||||
modules: ["modulePreferences"],
|
||||
notifications: ["moodReminderEnabled", "notificationPreferences"],
|
||||
thresholds: ["overrides"],
|
||||
coach: ["disableCoach", "preferences"],
|
||||
insights: ["layout"],
|
||||
} as const;
|
||||
|
||||
export type ManagedRecordSettingsFamily =
|
||||
keyof typeof MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST;
|
||||
|
||||
const timezoneSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine((timezone) => {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Expected an IANA timezone");
|
||||
|
||||
const profilePatchSchema = z
|
||||
.object({
|
||||
displayName: z.string().trim().min(1).max(100).nullable().optional(),
|
||||
heightCm: z.number().finite().min(30).max(300).nullable().optional(),
|
||||
dateOfBirth: z.string().date().nullable().optional(),
|
||||
gender: z.string().trim().min(1).max(64).nullable().optional(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable().optional(),
|
||||
timezone: timezoneSchema.optional(),
|
||||
unitPreference: z.enum(["metric", "imperial"]).optional(),
|
||||
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
|
||||
dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const insightsLayoutSchema = z
|
||||
.object({
|
||||
version: z.union([z.literal(1), z.literal(2)]),
|
||||
sections: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.enum(INSIGHTS_SECTION_IDS),
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.max(50)
|
||||
.optional(),
|
||||
tiles: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.enum(ACCEPTED_INSIGHTS_TILE_IDS),
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(1)
|
||||
.max(ACCEPTED_INSIGHTS_TILE_IDS.length)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Delivery ownership is the actor's personal channel choice. A Guardian may
|
||||
// set only the record's reminder timing and low-stock thresholds, never hand
|
||||
// a managed record to an actor's phone or provider channel.
|
||||
const managedNotificationPreferencesSchema = z
|
||||
.object({
|
||||
medication: z
|
||||
.object({
|
||||
lowStockRunwayDays: z.number().int().min(1).max(60).nullable(),
|
||||
reorderLeadDays: z.number().int().min(0).max(60),
|
||||
})
|
||||
.strict()
|
||||
.partial()
|
||||
.optional(),
|
||||
mood: z
|
||||
.object({ reminderHour: z.number().int().min(0).max(23) })
|
||||
.strict()
|
||||
.partial()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const managedCoachPreferencesSchema = coachPrefsSchema
|
||||
.pick({
|
||||
tone: true,
|
||||
verbosity: true,
|
||||
excludeMetrics: true,
|
||||
showEvidenceByDefault: true,
|
||||
defaultWindow: true,
|
||||
dataClusters: true,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS = {
|
||||
profile: profilePatchSchema,
|
||||
modules: z.object({ modulePreferences: modulePrefsPatchSchema }).strict(),
|
||||
notifications: z
|
||||
.object({
|
||||
moodReminderEnabled: z.boolean().optional(),
|
||||
notificationPreferences: managedNotificationPreferencesSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
thresholds: z.object({ overrides: thresholdsUpdateSchema }).strict(),
|
||||
coach: z
|
||||
.object({
|
||||
disableCoach: z.boolean().optional(),
|
||||
preferences: managedCoachPreferencesSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
insights: z.object({ layout: insightsLayoutSchema }).strict(),
|
||||
} as const;
|
||||
|
||||
export type ManagedRecordSettingsPatch = {
|
||||
profile: z.infer<typeof profilePatchSchema>;
|
||||
modules: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["modules"]>;
|
||||
notifications: z.infer<
|
||||
(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["notifications"]
|
||||
>;
|
||||
thresholds: z.infer<
|
||||
(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["thresholds"]
|
||||
>;
|
||||
coach: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["coach"]>;
|
||||
insights: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["insights"]>;
|
||||
};
|
||||
|
||||
export function isManagedRecordSettingsFamily(
|
||||
value: string,
|
||||
): value is ManagedRecordSettingsFamily {
|
||||
return value in MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS;
|
||||
}
|
||||
|
||||
/** Parse one named DTO family. Strict schemas make an actor field a 422. */
|
||||
export function parseManagedRecordSettingsPatch<
|
||||
Family extends ManagedRecordSettingsFamily,
|
||||
>(family: Family, value: unknown): ManagedRecordSettingsPatch[Family] {
|
||||
return MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS[family].parse(
|
||||
value,
|
||||
) as ManagedRecordSettingsPatch[Family];
|
||||
}
|
||||
|
||||
export function safeParseManagedRecordSettingsPatch<
|
||||
Family extends ManagedRecordSettingsFamily,
|
||||
>(family: Family, value: unknown) {
|
||||
return MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS[family].safeParse(value);
|
||||
}
|
||||
|
||||
/** Preserve only directly-owned module keys from a legacy persisted blob. */
|
||||
export function managedModulePreferencesFrom(
|
||||
raw: unknown,
|
||||
): ManagedRecordSettingsPatch["modules"]["modulePreferences"] {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const allowed: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const parsed = modulePrefsPatchSchema.safeParse({ [key]: value });
|
||||
if (parsed.success && value !== undefined) {
|
||||
allowed[key] = value as boolean;
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
@@ -12,3 +12,13 @@ export {
|
||||
type SettingsDestinationClassification,
|
||||
type SettingsDestinationKind,
|
||||
} from "./classification";
|
||||
export {
|
||||
isManagedRecordSettingsFamily,
|
||||
managedModulePreferencesFrom,
|
||||
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
|
||||
parseManagedRecordSettingsPatch,
|
||||
safeParseManagedRecordSettingsPatch,
|
||||
type ManagedRecordSettingsFamily,
|
||||
type ManagedRecordSettingsPatch,
|
||||
} from "./configuration";
|
||||
export { assertRecordSettingsResponseForRecord } from "./response";
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* A request carries its active record through the session cookie, so the
|
||||
* client must still reject a response that arrives after a tab switched to a
|
||||
* different record. TanStack keys prevent cache aliasing; this closes the
|
||||
* in-flight response race before it can populate that key.
|
||||
*/
|
||||
export function assertRecordSettingsResponseForRecord(
|
||||
response: { recordId: string },
|
||||
expectedRecordId: string | null,
|
||||
): void {
|
||||
if (expectedRecordId === null || response.recordId !== expectedRecordId) {
|
||||
throw new Error("Managed record settings response did not match its key");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
classifySettingsDestination,
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST } from "@/lib/record-settings";
|
||||
|
||||
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
@@ -48,4 +49,34 @@ describe("record settings authorization", () => {
|
||||
"unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses one guardian-only record resolver for every writable DTO family", async () => {
|
||||
const route = await readFile(
|
||||
fromRoot("src/app/api/record-settings/[family]/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const guardian = resolveGuardianRecordSettingsAccess({
|
||||
actor: { id: "guardian-1" },
|
||||
grantId: "grant-1",
|
||||
user: { id: "managed-record-1", managedProfileAt: new Date() },
|
||||
});
|
||||
const adultManager = resolveGuardianRecordSettingsAccess({
|
||||
actor: { id: "adult-manager" },
|
||||
grantId: "grant-1",
|
||||
user: { id: "adult-record-1", managedProfileAt: null },
|
||||
});
|
||||
|
||||
expect(guardian?.recordId).toBe("managed-record-1");
|
||||
expect(adultManager).toBeNull();
|
||||
expect(route).toContain("requireGuardianAuth");
|
||||
expect(route).toContain("resolveGuardianRecordSettingsAccess");
|
||||
for (const family of Object.keys(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST)) {
|
||||
expect(route).toContain(`case \"${family}\"`);
|
||||
}
|
||||
expect(route).toContain("record-settings.${family}.update");
|
||||
expect(route).toContain("userId: access.recordId");
|
||||
expect(route).toContain("actorUserId: access.actorId");
|
||||
expect(route).toContain("details: { changed }");
|
||||
expect(route).not.toContain("details: { recordId:");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user