mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge managed record settings
This commit is contained in:
commit
201a24e404
36 files changed
+3712
-14
No files matched your search
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user