Merge managed record settings

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 04:05:16 +02:00
commit 201a24e404
36 files changed
+3712 -14

No files matched your search

+11
View File
@@ -16,9 +16,20 @@ import {
resolveInsightsLayout,
serializeInsightsLayout,
orderedVisibleSectionIds,
normalizeInsightsTileId,
resolveTileLayout,
} from "@/lib/insights-layout";
describe("normalizeInsightsTileId", () => {
it("only resolves own legacy aliases", () => {
expect(normalizeInsightsTileId("blutdruck")).toBe("blood-pressure");
for (const id of ["toString", "constructor", "__proto__"]) {
expect(normalizeInsightsTileId(id)).toBe(id);
}
});
});
describe("insights-layout v2 — section id universe", () => {
it("carries exactly the default sections in render order", () => {
expect([...INSIGHTS_SECTION_IDS]).toEqual([
+3 -1
View File
@@ -87,7 +87,9 @@ export const ACCEPTED_INSIGHTS_TILE_IDS = [
* resolver's known-id filter drops anything truly unrecognised.
*/
export function normalizeInsightsTileId(id: string): string {
return LEGACY_INSIGHTS_TILE_ID_ALIASES[id] ?? id;
return Object.hasOwn(LEGACY_INSIGHTS_TILE_ID_ALIASES, id)
? LEGACY_INSIGHTS_TILE_ID_ALIASES[id]
: id;
}
/**
+4
View File
@@ -35,6 +35,7 @@ import { medicationKeys } from "./medications";
import { mentalHealthKeys } from "./mental-health";
import { moodKeys } from "./mood";
import { nutrientKeys } from "./nutrients";
import { recordSettingsKeys } from "./record-settings";
import { settingsKeys } from "./settings";
import { sharingKeys } from "./sharing";
import { workoutKeys } from "./workouts";
@@ -63,9 +64,12 @@ export const queryKeys = {
...documentKeys,
...customMetrics,
...nutrientKeys,
...recordSettingsKeys,
...sharingKeys,
};
export { recordSettingsKeys } from "./record-settings";
/**
* v1.36.0 — every read a grant transition can make stale.
*
+26
View File
@@ -0,0 +1,26 @@
/**
* Record configuration has its own cache family because it follows the active
* health record, while `authMe()` remains the person at the keyboard. Keeping
* the record id in every member prevents an actor cache cell from being read
* or invalidated as though it belonged to a switched record.
*/
export const recordSettingsKeys = {
root: () => ["record-settings"] as const,
detail: (recordId: string) =>
["record-settings", recordId, "detail"] as const,
integrations: (recordId: string) =>
["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,
recordSettingsIntegrations: (recordId: string) =>
["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,
};
+52
View File
@@ -0,0 +1,52 @@
export type RecordSettingsRelationship = "guardian" | "self" | "adult-manager";
export interface RecordSettingsAccess {
actorId: string;
recordId: string;
recordKind: "managed" | "self" | "shared";
relationship: RecordSettingsRelationship;
}
/**
* Assert a record-settings capability that is deliberately narrower than a
* general MANAGE grant. A MANAGE grant on an adult's ordinary record is not a
* route into their identity or configuration.
*/
export function assertRecordSettingsAccess(
access: RecordSettingsAccess,
destination: "guardian",
): void {
if (
destination === "guardian" &&
(access.recordKind !== "managed" || access.relationship !== "guardian")
) {
throw new Error("Guardian configuration is unavailable for this record");
}
}
/**
* Convert the resolver's authenticated record into the explicit settings
* boundary. The resolver authenticates the active grant; this function keeps
* the managed-record marker and relationship requirement visible at every
* record-settings route.
*/
export function resolveGuardianRecordSettingsAccess(context: {
actor: { id: string };
grantId: string | null;
user: { id: string; managedProfileAt: Date | null };
}): RecordSettingsAccess | null {
const access: RecordSettingsAccess = {
actorId: context.actor.id,
recordId: context.user.id,
recordKind: context.user.managedProfileAt ? "managed" : "self",
relationship: context.grantId ? "guardian" : "self",
};
try {
assertRecordSettingsAccess(access, "guardian");
} catch {
return null;
}
return access;
}
+92
View File
@@ -0,0 +1,92 @@
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
export type SettingsDestinationKind =
"personal" | "managed-guardian" | "adult-shared-unavailable" | "unavailable";
export interface SettingsDestinationClassification {
kind: SettingsDestinationKind;
guardianWritable: boolean;
}
const PERSONAL: SettingsDestinationClassification = {
kind: "personal",
guardianWritable: false,
};
const MANAGED_GUARDIAN: SettingsDestinationClassification = {
kind: "managed-guardian",
guardianWritable: true,
};
const MANAGED_GUARDIAN_STATUS: SettingsDestinationClassification = {
kind: "managed-guardian",
guardianWritable: false,
};
const ADULT_SHARED_UNAVAILABLE: SettingsDestinationClassification = {
kind: "adult-shared-unavailable",
guardianWritable: false,
};
const UNAVAILABLE: SettingsDestinationClassification = {
kind: "unavailable",
guardianWritable: false,
};
/**
* The complete Settings route inventory. `satisfies Record<SettingsSectionSlug,
* ...>` makes adding a new destination a compile error until its record
* classification is reviewed. Unknown destinations resolve to unavailable so
* direct links fail closed rather than inheriting a neighbouring category.
*/
export const SETTINGS_DESTINATION_INVENTORY = {
account: MANAGED_GUARDIAN,
security: PERSONAL,
access: PERSONAL,
modules: MANAGED_GUARDIAN,
integrations: MANAGED_GUARDIAN_STATUS,
sources: UNAVAILABLE,
notifications: MANAGED_GUARDIAN,
layout: ADULT_SHARED_UNAVAILABLE,
dashboard: ADULT_SHARED_UNAVAILABLE,
insights: MANAGED_GUARDIAN,
medications: ADULT_SHARED_UNAVAILABLE,
mood: ADULT_SHARED_UNAVAILABLE,
labs: ADULT_SHARED_UNAVAILABLE,
illness: ADULT_SHARED_UNAVAILABLE,
environment: UNAVAILABLE,
anamnesis: ADULT_SHARED_UNAVAILABLE,
score: ADULT_SHARED_UNAVAILABLE,
vorsorge: ADULT_SHARED_UNAVAILABLE,
thresholds: MANAGED_GUARDIAN,
ai: UNAVAILABLE,
coach: MANAGED_GUARDIAN,
api: PERSONAL,
mcp: PERSONAL,
gesundheitsakte: PERSONAL,
sharing: UNAVAILABLE,
export: PERSONAL,
advanced: PERSONAL,
privacy: PERSONAL,
about: PERSONAL,
} as const satisfies Record<
SettingsSectionSlug,
SettingsDestinationClassification
>;
export function classifySettingsDestination(
destination: string,
): SettingsDestinationClassification {
return (
SETTINGS_DESTINATION_INVENTORY[destination as SettingsSectionSlug] ??
UNAVAILABLE
);
}
export function isGuardianSettingsWriteAllowed(destination: string): boolean {
const classification = classifySettingsDestination(destination);
return (
classification.kind === "managed-guardian" &&
classification.guardianWritable
);
}
+215
View File
@@ -0,0 +1,215 @@
import { z } from "zod/v4";
import {
ACCEPTED_INSIGHTS_TILE_IDS,
INSIGHTS_SECTION_IDS,
} from "@/lib/insights-layout";
import { MODULE_REGISTRY } from "@/lib/modules/registry";
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
import {
modulePrefsPatchSchema,
WRITABLE_MODULE_KEYS,
} 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.enum(["MALE", "FEMALE", "OTHER"]).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 Object.hasOwn(MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS, value);
}
/** 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);
}
/**
* The same direct module inventory and default posture as `/api/auth/me/modules`.
* Every entry is present for a fresh record so a Guardian can administer the
* whole supported surface instead of seeing only previously persisted keys.
*/
export const MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS = Object.freeze(
Object.fromEntries(
WRITABLE_MODULE_KEYS.map((key) => [key, !MODULE_REGISTRY[key].optIn]),
),
) as Readonly<Record<(typeof WRITABLE_MODULE_KEYS)[number], boolean>>;
/** Preserve only directly-owned module keys from a legacy persisted blob. */
export function managedModulePreferencesFrom(
raw: unknown,
): ManagedRecordSettingsPatch["modules"]["modulePreferences"] {
const allowed: Record<string, boolean> = {
...MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
};
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return allowed;
}
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;
}
+32
View File
@@ -0,0 +1,32 @@
export interface RecordSettingsDto {
record: {
id: string;
displayName: string;
locale: string | null;
timezone: string;
kind: "managed";
};
}
/**
* The record-settings payload intentionally names the record it describes.
* It is never an extension of `/api/auth/me`, which remains the actor's
* identity payload while the browser is switched into another record.
*/
export function toRecordSettingsDto(source: {
id: string;
name: string | null;
locale: string | null;
timezone: string;
recordKind: "managed";
}): RecordSettingsDto {
return {
record: {
id: source.id,
displayName: source.name ?? "",
locale: source.locale,
timezone: source.timezone,
kind: source.recordKind,
},
};
}
+25
View File
@@ -0,0 +1,25 @@
export {
assertRecordSettingsAccess,
resolveGuardianRecordSettingsAccess,
type RecordSettingsAccess,
} from "./access";
export { toRecordSettingsDto, type RecordSettingsDto } from "./dto";
export { resolveManagedIntegrationState } from "./integrations";
export {
classifySettingsDestination,
isGuardianSettingsWriteAllowed,
SETTINGS_DESTINATION_INVENTORY,
type SettingsDestinationClassification,
type SettingsDestinationKind,
} from "./classification";
export {
isManagedRecordSettingsFamily,
managedModulePreferencesFrom,
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
parseManagedRecordSettingsPatch,
safeParseManagedRecordSettingsPatch,
type ManagedRecordSettingsFamily,
type ManagedRecordSettingsPatch,
} from "./configuration";
export { assertRecordSettingsResponseForRecord } from "./response";
+18
View File
@@ -0,0 +1,18 @@
import type {
IntegrationKey,
IntegrationState,
} from "@/lib/integrations/status";
/**
* A status-ledger row alone cannot prove a connection: integrations without a
* row synthesize a healthy ledger state for the owner Settings dashboard. The
* managed projection must instead report those as disconnected until its
* record carries a real credential or connection row.
*/
export function resolveManagedIntegrationState(
state: IntegrationState,
connected: Record<IntegrationKey, boolean>,
integration: IntegrationKey,
): IntegrationState {
return connected[integration] ? state : "disconnected";
}
+14
View File
@@ -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");
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ import {
* opt into it gets a clean 422 rather than persisting an inert `true` the
* gate would ignore anyway.
*/
const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
export const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
(k) => moduleDelegatesTo(k) === undefined && !isCodeDisabledModule(k),
);