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

+113
View File
@@ -4433,6 +4433,119 @@
"system": "System"
}
},
"sharedRecord": {
"managedSettings": {
"profile": {
"title": "Profil",
"displayName": "Anzeigename",
"height": "Größe (cm)",
"dateOfBirth": "Geburtsdatum",
"gender": "Geschlecht",
"notSpecified": "Nicht angegeben",
"male": "Männlich",
"female": "Weiblich",
"other": "Andere Angabe",
"locale": "Sprache",
"systemDefault": "Systemstandard",
"timezone": "Zeitzone",
"units": "Einheiten",
"metric": "Metrisch",
"imperial": "Imperial",
"timeFormat": "Zeitformat",
"automatic": "Automatisch",
"hour12": "12 Stunden",
"hour24": "24 Stunden",
"dateFormat": "Datumsformat",
"dateDmy": "Tag / Monat / Jahr",
"dateMdy": "Monat / Tag / Jahr",
"dateYmd": "Jahr / Monat / Tag"
},
"modules": { "title": "Module" },
"notifications": {
"title": "Benachrichtigungen",
"moodReminder": "Stimmungserinnerung aktivieren",
"reminderHour": "Uhrzeit der Stimmungserinnerung",
"lowStockRunwayDays": "Tage bis zum niedrigen Bestand",
"reorderLeadDays": "Vorlaufzeit für Nachbestellung"
},
"thresholds": {
"title": "Zielwerte",
"metric": "Messwert",
"minimum": "Minimum",
"maximum": "Maximum"
},
"coach": {
"title": "Coach",
"disable": "Coach deaktivieren",
"tone": "Ton",
"warm": "Herzlich",
"neutral": "Neutral",
"concise": "Knapp",
"verbosity": "Ausführlichkeit",
"brief": "Kurz",
"default": "Standard",
"detailed": "Ausführlich",
"defaultWindow": "Standardzeitraum",
"last7days": "Letzte 7 Tage",
"last30days": "Letzte 30 Tage",
"last90days": "Letzte 90 Tage",
"allTime": "Gesamter Zeitraum",
"showEvidence": "Evidenz standardmäßig anzeigen",
"excludedMetrics": "Ausgeschlossene Messwerte",
"dataClusters": "Datenbereiche",
"customClusters": "Eigene Auswahl von Datenbereichen verwenden",
"metrics": {
"bp": "Blutdruck",
"weight": "Gewicht",
"pulse": "Puls",
"mood": "Stimmung",
"compliance": "Medikamenteneinnahme",
"hrv": "Herzfrequenzvariabilität",
"sleep": "Schlaf",
"restingHr": "Ruhepuls",
"steps": "Schritte",
"medications": "Medikamente",
"anthropometrics": "Körpermaße"
},
"clusters": {
"cardio": "Herz-Kreislauf",
"body": "Körperzusammensetzung",
"activity": "Aktivität",
"workouts": "Trainings",
"sleep": "Schlaf",
"mood": "Stimmung",
"glucose": "Glukose",
"medication": "Medikation",
"mobility": "Mobilität",
"environment": "Umgebung"
}
},
"insights": {
"title": "Erkenntnisse",
"sections": "Übersichtsbereiche",
"tiles": "Navigationskacheln",
"order": "Reihenfolge",
"unknownItem": "Nicht verfügbare Erkenntnis",
"sectionLabels": {
"wellnessScores": "Wellnesswerte",
"dailyBriefing": "Tagesüberblick",
"vitals": "Vitalwerte",
"trends": "Trends",
"periodReview": "Periodenrückblick",
"cycleSummary": "Zyklusübersicht",
"signals": "Signale",
"rhythmEvents": "Rhythmusereignisse",
"healthStatus": "Gesundheitsstatus",
"breathing": "Atmung",
"labsChanges": "Laborveränderungen",
"ecg": "EKG"
}
}
},
"unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar",
"unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.",
"integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz. Verbindungssteuerungen sind nicht verfügbar."
},
"sections": {
"account": {
"title": "Konto",
+113
View File
@@ -4433,6 +4433,119 @@
"system": "System"
}
},
"sharedRecord": {
"managedSettings": {
"profile": {
"title": "Profile",
"displayName": "Display name",
"height": "Height (cm)",
"dateOfBirth": "Date of birth",
"gender": "Gender",
"notSpecified": "Not specified",
"male": "Male",
"female": "Female",
"other": "Other",
"locale": "Locale",
"systemDefault": "System default",
"timezone": "Timezone",
"units": "Units",
"metric": "Metric",
"imperial": "Imperial",
"timeFormat": "Time format",
"automatic": "Automatic",
"hour12": "12-hour",
"hour24": "24-hour",
"dateFormat": "Date format",
"dateDmy": "Day / month / year",
"dateMdy": "Month / day / year",
"dateYmd": "Year / month / day"
},
"modules": { "title": "Modules" },
"notifications": {
"title": "Notifications",
"moodReminder": "Enable mood reminder",
"reminderHour": "Mood reminder hour",
"lowStockRunwayDays": "Low-stock runway days",
"reorderLeadDays": "Reorder lead days"
},
"thresholds": {
"title": "Targets",
"metric": "Metric",
"minimum": "Minimum",
"maximum": "Maximum"
},
"coach": {
"title": "Coach",
"disable": "Disable Coach",
"tone": "Tone",
"warm": "Warm",
"neutral": "Neutral",
"concise": "Concise",
"verbosity": "Verbosity",
"brief": "Brief",
"default": "Default",
"detailed": "Detailed",
"defaultWindow": "Default window",
"last7days": "Last 7 days",
"last30days": "Last 30 days",
"last90days": "Last 90 days",
"allTime": "All time",
"showEvidence": "Show evidence by default",
"excludedMetrics": "Excluded metrics",
"dataClusters": "Data clusters",
"customClusters": "Use a custom cluster selection",
"metrics": {
"bp": "Blood pressure",
"weight": "Weight",
"pulse": "Pulse",
"mood": "Mood",
"compliance": "Medication adherence",
"hrv": "Heart rate variability",
"sleep": "Sleep",
"restingHr": "Resting heart rate",
"steps": "Steps",
"medications": "Medications",
"anthropometrics": "Body measurements"
},
"clusters": {
"cardio": "Cardiovascular health",
"body": "Body composition",
"activity": "Activity",
"workouts": "Workouts",
"sleep": "Sleep",
"mood": "Mood",
"glucose": "Glucose",
"medication": "Medication",
"mobility": "Mobility",
"environment": "Environment"
}
},
"insights": {
"title": "Insights",
"sections": "Overview sections",
"tiles": "Navigation tiles",
"order": "Order",
"unknownItem": "Unavailable insight",
"sectionLabels": {
"wellnessScores": "Wellness scores",
"dailyBriefing": "Daily briefing",
"vitals": "Vitals",
"trends": "Trends",
"periodReview": "Period review",
"cycleSummary": "Cycle summary",
"signals": "Signals",
"rhythmEvents": "Rhythm events",
"healthStatus": "Health status",
"breathing": "Breathing",
"labsChanges": "Lab changes",
"ecg": "ECG"
}
}
},
"unavailableTitle": "Unavailable in this shared record",
"unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.",
"integrationStatusDescription": "Connection status for this managed record. Connection controls are unavailable."
},
"sections": {
"account": {
"title": "Account",
+113
View File
@@ -4433,6 +4433,119 @@
"system": "Sistema"
}
},
"sharedRecord": {
"managedSettings": {
"profile": {
"title": "Perfil",
"displayName": "Nombre visible",
"height": "Altura (cm)",
"dateOfBirth": "Fecha de nacimiento",
"gender": "Género",
"notSpecified": "No especificado",
"male": "Hombre",
"female": "Mujer",
"other": "Otro",
"locale": "Idioma",
"systemDefault": "Predeterminado del sistema",
"timezone": "Zona horaria",
"units": "Unidades",
"metric": "Métrico",
"imperial": "Imperial",
"timeFormat": "Formato de hora",
"automatic": "Automático",
"hour12": "12 horas",
"hour24": "24 horas",
"dateFormat": "Formato de fecha",
"dateDmy": "Día / mes / año",
"dateMdy": "Mes / día / año",
"dateYmd": "Año / mes / día"
},
"modules": { "title": "Módulos" },
"notifications": {
"title": "Notificaciones",
"moodReminder": "Activar recordatorio de ánimo",
"reminderHour": "Hora del recordatorio de ánimo",
"lowStockRunwayDays": "Días hasta existencias bajas",
"reorderLeadDays": "Días de margen para reponer"
},
"thresholds": {
"title": "Objetivos",
"metric": "Métrica",
"minimum": "Mínimo",
"maximum": "Máximo"
},
"coach": {
"title": "Asistente",
"disable": "Desactivar asistente",
"tone": "Tono",
"warm": "Cálido",
"neutral": "Neutro",
"concise": "Conciso",
"verbosity": "Nivel de detalle",
"brief": "Breve",
"default": "Predeterminado",
"detailed": "Detallado",
"defaultWindow": "Periodo predeterminado",
"last7days": "Últimos 7 días",
"last30days": "Últimos 30 días",
"last90days": "Últimos 90 días",
"allTime": "Todo el tiempo",
"showEvidence": "Mostrar evidencia de forma predeterminada",
"excludedMetrics": "Métricas excluidas",
"dataClusters": "Grupos de datos",
"customClusters": "Usar una selección personalizada de grupos",
"metrics": {
"bp": "Presión arterial",
"weight": "Peso",
"pulse": "Pulso",
"mood": "Ánimo",
"compliance": "Adherencia a la medicación",
"hrv": "Variabilidad de la frecuencia cardíaca",
"sleep": "Sueño",
"restingHr": "Frecuencia cardíaca en reposo",
"steps": "Pasos",
"medications": "Medicamentos",
"anthropometrics": "Medidas corporales"
},
"clusters": {
"cardio": "Salud cardiovascular",
"body": "Composición corporal",
"activity": "Actividad",
"workouts": "Entrenamientos",
"sleep": "Sueño",
"mood": "Ánimo",
"glucose": "Glucosa",
"medication": "Medicación",
"mobility": "Movilidad",
"environment": "Entorno"
}
},
"insights": {
"title": "Análisis",
"sections": "Secciones del resumen",
"tiles": "Mosaicos de navegación",
"order": "Orden",
"unknownItem": "Análisis no disponible",
"sectionLabels": {
"wellnessScores": "Puntuaciones de bienestar",
"dailyBriefing": "Resumen diario",
"vitals": "Signos vitales",
"trends": "Tendencias",
"periodReview": "Revisión del período",
"cycleSummary": "Resumen del ciclo",
"signals": "Señales",
"rhythmEvents": "Eventos de ritmo",
"healthStatus": "Estado de salud",
"breathing": "Respiración",
"labsChanges": "Cambios de laboratorio",
"ecg": "ECG"
}
}
},
"unavailableTitle": "No disponible en este registro compartido",
"unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.",
"integrationStatusDescription": "Estado de conexión de este registro gestionado. Los controles de conexión no están disponibles."
},
"sections": {
"account": {
"title": "Cuenta",
+113
View File
@@ -4433,6 +4433,119 @@
"system": "Système"
}
},
"sharedRecord": {
"managedSettings": {
"profile": {
"title": "Profil",
"displayName": "Nom affiché",
"height": "Taille (cm)",
"dateOfBirth": "Date de naissance",
"gender": "Genre",
"notSpecified": "Non précisé",
"male": "Homme",
"female": "Femme",
"other": "Autre",
"locale": "Langue",
"systemDefault": "Valeur système",
"timezone": "Fuseau horaire",
"units": "Unités",
"metric": "Métrique",
"imperial": "Impérial",
"timeFormat": "Format de l’heure",
"automatic": "Automatique",
"hour12": "12 heures",
"hour24": "24 heures",
"dateFormat": "Format de date",
"dateDmy": "Jour / mois / année",
"dateMdy": "Mois / jour / année",
"dateYmd": "Année / mois / jour"
},
"modules": { "title": "Modules" },
"notifications": {
"title": "Notifications",
"moodReminder": "Activer le rappel d’humeur",
"reminderHour": "Heure du rappel d’humeur",
"lowStockRunwayDays": "Jours avant stock faible",
"reorderLeadDays": "Délai de réapprovisionnement"
},
"thresholds": {
"title": "Objectifs",
"metric": "Mesure",
"minimum": "Minimum",
"maximum": "Maximum"
},
"coach": {
"title": "Coach",
"disable": "Désactiver le Coach",
"tone": "Ton",
"warm": "Chaleureux",
"neutral": "Neutre",
"concise": "Concis",
"verbosity": "Niveau de détail",
"brief": "Bref",
"default": "Par défaut",
"detailed": "Détaillé",
"defaultWindow": "Période par défaut",
"last7days": "7 derniers jours",
"last30days": "30 derniers jours",
"last90days": "90 derniers jours",
"allTime": "Depuis toujours",
"showEvidence": "Afficher les sources par défaut",
"excludedMetrics": "Mesures exclues",
"dataClusters": "Groupes de données",
"customClusters": "Utiliser une sélection personnalisée de groupes",
"metrics": {
"bp": "Tension artérielle",
"weight": "Poids",
"pulse": "Pouls",
"mood": "Humeur",
"compliance": "Adhérence aux médicaments",
"hrv": "Variabilité de la fréquence cardiaque",
"sleep": "Sommeil",
"restingHr": "Fréquence cardiaque au repos",
"steps": "Pas",
"medications": "Médicaments",
"anthropometrics": "Mesures corporelles"
},
"clusters": {
"cardio": "Santé cardiovasculaire",
"body": "Composition corporelle",
"activity": "Activité",
"workouts": "Entraînements",
"sleep": "Sommeil",
"mood": "Humeur",
"glucose": "Glucose",
"medication": "Médicaments",
"mobility": "Mobilité",
"environment": "Environnement"
}
},
"insights": {
"title": "Analyses",
"sections": "Sections d’aperçu",
"tiles": "Tuiles de navigation",
"order": "Ordre",
"unknownItem": "Analyse indisponible",
"sectionLabels": {
"wellnessScores": "Scores de bien-être",
"dailyBriefing": "Brief quotidien",
"vitals": "Signes vitaux",
"trends": "Tendances",
"periodReview": "Bilan de période",
"cycleSummary": "Résumé du cycle",
"signals": "Signaux",
"rhythmEvents": "Événements de rythme",
"healthStatus": "État de santé",
"breathing": "Respiration",
"labsChanges": "Évolutions de laboratoire",
"ecg": "ECG"
}
}
},
"unavailableTitle": "Indisponible dans ce dossier partagé",
"unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.",
"integrationStatusDescription": "État de connexion pour ce dossier géré. Les commandes de connexion ne sont pas disponibles."
},
"sections": {
"account": {
"title": "Compte",
+113
View File
@@ -4433,6 +4433,119 @@
"system": "Sistema"
}
},
"sharedRecord": {
"managedSettings": {
"profile": {
"title": "Profilo",
"displayName": "Nome visualizzato",
"height": "Altezza (cm)",
"dateOfBirth": "Data di nascita",
"gender": "Genere",
"notSpecified": "Non specificato",
"male": "Uomo",
"female": "Donna",
"other": "Altro",
"locale": "Lingua",
"systemDefault": "Predefinito di sistema",
"timezone": "Fuso orario",
"units": "Unità",
"metric": "Metrico",
"imperial": "Imperiale",
"timeFormat": "Formato ora",
"automatic": "Automatico",
"hour12": "12 ore",
"hour24": "24 ore",
"dateFormat": "Formato data",
"dateDmy": "Giorno / mese / anno",
"dateMdy": "Mese / giorno / anno",
"dateYmd": "Anno / mese / giorno"
},
"modules": { "title": "Moduli" },
"notifications": {
"title": "Notifiche",
"moodReminder": "Attiva promemoria dell’umore",
"reminderHour": "Ora del promemoria dell’umore",
"lowStockRunwayDays": "Giorni prima delle scorte basse",
"reorderLeadDays": "Margine per il riordino"
},
"thresholds": {
"title": "Obiettivi",
"metric": "Metrica",
"minimum": "Minimo",
"maximum": "Massimo"
},
"coach": {
"title": "Coach",
"disable": "Disattiva Coach",
"tone": "Tono",
"warm": "Caldo",
"neutral": "Neutro",
"concise": "Conciso",
"verbosity": "Livello di dettaglio",
"brief": "Breve",
"default": "Predefinito",
"detailed": "Dettagliato",
"defaultWindow": "Periodo predefinito",
"last7days": "Ultimi 7 giorni",
"last30days": "Ultimi 30 giorni",
"last90days": "Ultimi 90 giorni",
"allTime": "Tutto il periodo",
"showEvidence": "Mostra le fonti per impostazione predefinita",
"excludedMetrics": "Metriche escluse",
"dataClusters": "Gruppi di dati",
"customClusters": "Usa una selezione personalizzata di gruppi",
"metrics": {
"bp": "Pressione arteriosa",
"weight": "Peso",
"pulse": "Polso",
"mood": "Umore",
"compliance": "Aderenza ai farmaci",
"hrv": "Variabilità della frequenza cardiaca",
"sleep": "Sonno",
"restingHr": "Frequenza cardiaca a riposo",
"steps": "Passi",
"medications": "Farmaci",
"anthropometrics": "Misure corporee"
},
"clusters": {
"cardio": "Salute cardiovascolare",
"body": "Composizione corporea",
"activity": "Attività",
"workouts": "Allenamenti",
"sleep": "Sonno",
"mood": "Umore",
"glucose": "Glucosio",
"medication": "Farmaci",
"mobility": "Mobilità",
"environment": "Ambiente"
}
},
"insights": {
"title": "Analisi",
"sections": "Sezioni della panoramica",
"tiles": "Riquadri di navigazione",
"order": "Ordine",
"unknownItem": "Analisi non disponibile",
"sectionLabels": {
"wellnessScores": "Punteggi di benessere",
"dailyBriefing": "Riepilogo giornaliero",
"vitals": "Parametri vitali",
"trends": "Tendenze",
"periodReview": "Riepilogo del periodo",
"cycleSummary": "Riepilogo del ciclo",
"signals": "Segnali",
"rhythmEvents": "Eventi del ritmo",
"healthStatus": "Stato di salute",
"breathing": "Respirazione",
"labsChanges": "Variazioni di laboratorio",
"ecg": "ECG"
}
}
},
"unavailableTitle": "Non disponibile in questo record condiviso",
"unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.",
"integrationStatusDescription": "Stato di connessione per questo record gestito. I controlli di connessione non sono disponibili."
},
"sections": {
"account": {
"title": "Account",
+113
View File
@@ -4433,6 +4433,119 @@
"system": "System"
}
},
"sharedRecord": {
"managedSettings": {
"profile": {
"title": "Profil",
"displayName": "Wyświetlana nazwa",
"height": "Wzrost (cm)",
"dateOfBirth": "Data urodzenia",
"gender": "Płeć",
"notSpecified": "Nie podano",
"male": "Mężczyzna",
"female": "Kobieta",
"other": "Inna",
"locale": "Język",
"systemDefault": "Ustawienie systemowe",
"timezone": "Strefa czasowa",
"units": "Jednostki",
"metric": "Metryczne",
"imperial": "Imperialne",
"timeFormat": "Format czasu",
"automatic": "Automatyczny",
"hour12": "12-godzinny",
"hour24": "24-godzinny",
"dateFormat": "Format daty",
"dateDmy": "Dzień / miesiąc / rok",
"dateMdy": "Miesiąc / dzień / rok",
"dateYmd": "Rok / miesiąc / dzień"
},
"modules": { "title": "Moduły" },
"notifications": {
"title": "Powiadomienia",
"moodReminder": "Włącz przypomnienie nastroju",
"reminderHour": "Godzina przypomnienia nastroju",
"lowStockRunwayDays": "Dni do niskiego stanu zapasów",
"reorderLeadDays": "Wyprzedzenie ponownego zamówienia"
},
"thresholds": {
"title": "Cele",
"metric": "Metryka",
"minimum": "Minimum",
"maximum": "Maksimum"
},
"coach": {
"title": "Trener",
"disable": "Wyłącz trenera",
"tone": "Ton",
"warm": "Ciepły",
"neutral": "Neutralny",
"concise": "Zwięzły",
"verbosity": "Szczegółowość",
"brief": "Krótka",
"default": "Domyślna",
"detailed": "Szczegółowa",
"defaultWindow": "Domyślny okres",
"last7days": "Ostatnie 7 dni",
"last30days": "Ostatnie 30 dni",
"last90days": "Ostatnie 90 dni",
"allTime": "Cały okres",
"showEvidence": "Domyślnie pokazuj źródła",
"excludedMetrics": "Wykluczone metryki",
"dataClusters": "Grupy danych",
"customClusters": "Użyj własnego wyboru grup",
"metrics": {
"bp": "Ciśnienie krwi",
"weight": "Masa ciała",
"pulse": "Tętno",
"mood": "Nastrój",
"compliance": "Przestrzeganie przyjmowania leków",
"hrv": "Zmienność rytmu serca",
"sleep": "Sen",
"restingHr": "Tętno spoczynkowe",
"steps": "Kroki",
"medications": "Leki",
"anthropometrics": "Wymiary ciała"
},
"clusters": {
"cardio": "Zdrowie sercowo-naczyniowe",
"body": "Skład ciała",
"activity": "Aktywność",
"workouts": "Treningi",
"sleep": "Sen",
"mood": "Nastrój",
"glucose": "Glukoza",
"medication": "Leki",
"mobility": "Mobilność",
"environment": "Środowisko"
}
},
"insights": {
"title": "Wnioski",
"sections": "Sekcje przeglądu",
"tiles": "Kafelki nawigacji",
"order": "Kolejność",
"unknownItem": "Niedostępna analiza",
"sectionLabels": {
"wellnessScores": "Wyniki dobrostanu",
"dailyBriefing": "Codzienny przegląd",
"vitals": "Parametry życiowe",
"trends": "Trendy",
"periodReview": "Przegląd okresu",
"cycleSummary": "Podsumowanie cyklu",
"signals": "Sygnały",
"rhythmEvents": "Zdarzenia rytmu",
"healthStatus": "Stan zdrowia",
"breathing": "Oddychanie",
"labsChanges": "Zmiany wyników laboratoryjnych",
"ecg": "EKG"
}
}
},
"unavailableTitle": "Niedostępne w tym udostępnionym rekordzie",
"unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.",
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu. Elementy sterujące połączeniem są niedostępne."
},
"sections": {
"account": {
"title": "Konto",
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
isManagedRecordSettingsFamily,
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
managedModulePreferencesFrom,
parseManagedRecordSettingsPatch,
} from "@/lib/record-settings/configuration";
import { WRITABLE_MODULE_KEYS } from "@/lib/validations/modules";
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();
});
it("uses the complete canonical module inventory for a fresh record", () => {
expect(Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)).toEqual(
WRITABLE_MODULE_KEYS,
);
expect(managedModulePreferencesFrom(null)).toEqual(
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
);
});
it("accepts only the canonical profile gender enum", () => {
expect(() =>
parseManagedRecordSettingsPatch("profile", { gender: "MALE" }),
).not.toThrow();
expect(() =>
parseManagedRecordSettingsPatch("profile", { gender: "UNKNOWN" }),
).toThrow();
});
it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])(
"rejects inherited family name %s",
(family) => {
expect(isManagedRecordSettingsFamily(family)).toBe(false);
},
);
});
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
assertRecordSettingsAccess,
type RecordSettingsAccess,
} from "@/lib/record-settings/access";
import {
classifySettingsDestination,
isGuardianSettingsWriteAllowed,
SETTINGS_DESTINATION_INVENTORY,
} 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", () => {
it("names the target record in both the DTO and cache key", () => {
const dto = toRecordSettingsDto({
id: "record-1",
name: "Managed profile",
locale: "de",
timezone: "Europe/Berlin",
recordKind: "managed",
});
expect(dto).toEqual({
record: {
id: "record-1",
displayName: "Managed profile",
locale: "de",
timezone: "Europe/Berlin",
kind: "managed",
},
});
expect(recordSettingsKeys.detail("record-1")).toEqual([
"record-settings",
"record-1",
"detail",
]);
expect(recordSettingsKeys.recordSettingsIntegrations("record-1")).toEqual([
"record-settings",
"record-1",
"integrations",
]);
});
it("does not let an adult MANAGE grant use Guardian-only configuration", () => {
const access: RecordSettingsAccess = {
actorId: "adult-manager",
recordId: "managed-record",
recordKind: "managed",
relationship: "adult-manager",
};
expect(() => assertRecordSettingsAccess(access, "guardian")).toThrow(
"Guardian configuration is unavailable for this record",
);
});
it("classifies every Settings destination and limits Guardian writes", () => {
expect(Object.keys(SETTINGS_DESTINATION_INVENTORY)).not.toHaveLength(0);
expect(classifySettingsDestination("integrations")).toMatchObject({
kind: "managed-guardian",
guardianWritable: false,
});
expect(classifySettingsDestination("ai").kind).toBe("unavailable");
expect(classifySettingsDestination("dashboard").kind).toBe(
"adult-shared-unavailable",
);
expect(isGuardianSettingsWriteAllowed("account")).toBe(true);
expect(isGuardianSettingsWriteAllowed("notifications")).toBe(true);
expect(isGuardianSettingsWriteAllowed("integrations")).toBe(false);
expect(isGuardianSettingsWriteAllowed("ai")).toBe(false);
});
it("does not report a synthetic ledger row as a managed connection", () => {
const connected = {
withings: false,
whoop: false,
fitbit: false,
nightscout: false,
polar: false,
oura: false,
"google-health": false,
strava: false,
} as const;
expect(
resolveManagedIntegrationState("connected", connected, "withings"),
).toBe("disconnected");
expect(
resolveManagedIntegrationState(
"error_reauth",
{ ...connected, oura: true },
"oura",
),
).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,66 @@
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}\"`);
}
expect(source).toContain("if (!isManagedRecordSettingsFamily(rawFamily))");
expect(source).toContain('apiError("Settings family not found", 404)');
expect(
source.indexOf("isManagedRecordSettingsFamily(rawFamily)"),
).toBeLessThan(source.indexOf("const context = await requireGuardianAuth"));
});
});
+105
View File
@@ -0,0 +1,105 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
if (
expected === null ||
typeof expected !== "object" ||
actual === null ||
typeof actual !== "object"
) {
expect(typeof actual).toBe(typeof expected);
return;
}
const expectedRecord = expected as Record<string, unknown>;
const actualRecord = actual as Record<string, unknown>;
expect(Object.keys(actualRecord).sort()).toEqual(
Object.keys(expectedRecord).sort(),
);
for (const key of Object.keys(expectedRecord)) {
expectMatchingMessageKeys(expectedRecord[key], actualRecord[key]);
}
}
describe("managed record settings UI", () => {
it("gates shared deep links and renders integration status without controls", async () => {
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");
expect(managedSettingsView).not.toContain("Display name");
expect(managedSettingsView).not.toContain("Enable mood reminder");
const referenceMessages = JSON.parse(localeBundles[0]).settings.sharedRecord
.managedSettings;
for (const bundle of localeBundles) {
const sharedRecord = JSON.parse(bundle).settings.sharedRecord;
expect(sharedRecord).toMatchObject({
unavailableTitle: expect.any(String),
integrationStatusDescription: expect.any(String),
managedSettings: {
profile: {
title: expect.any(String),
displayName: expect.any(String),
},
modules: { title: expect.any(String) },
notifications: { title: expect.any(String) },
thresholds: { title: expect.any(String) },
coach: { title: expect.any(String) },
insights: { title: expect.any(String) },
},
});
expectMatchingMessageKeys(
referenceMessages,
sharedRecord.managedSettings,
);
}
});
});
+12 -3
View File
@@ -1745,7 +1745,14 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
* the auth layer, or "no module reaches it" would be true because the symbol
* had been renamed rather than because no module reaches it.
*/
const GUARDIAN_ROUTES: Record<string, string> = {};
const GUARDIAN_ROUTES: Record<string, string> = {
"app/api/record-settings/route.ts":
"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.",
};
/**
* Route modules that deliberately resolve the ACTOR and keep working under a
@@ -1784,9 +1791,11 @@ const ACTOR_ROUTES: Record<string, string> = {
* number rather than one per list on purpose: filling either of the new
* 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.
* adds two record entries and two MANAGE entries: 190 → 194. Managed-record
* settings add two guardian-only entries: 194 → 196. Field-specific
* managed-record configuration adds one more: 196 → 197.
*/
const FROZEN_ENTRY_COUNT = 194;
const FROZEN_ENTRY_COUNT = 197;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -0,0 +1,396 @@
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 {
invalidateUserHealthScore,
invalidateUserInsightsLayout,
invalidateUserMedications,
invalidateUserProfile,
} from "@/lib/cache/invalidate";
import { prisma, toJson } from "@/lib/db";
import {
getAllEffectiveRanges,
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": {
const overrides = (record.thresholdsJson ??
{}) as ThresholdOverridesJson;
settings = {
overrides,
effective: getAllEffectiveRanges(record, overrides),
};
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);
if (patch.timezone !== undefined) {
invalidateUserMedications(access.recordId, { evict: true });
}
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) },
});
invalidateUserHealthScore(access.recordId);
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,
};
const record = await prisma.user.update({
where: { id: access.recordId },
data: { thresholdsJson: toJson(overrides) },
select: { heightCm: true, dateOfBirth: true, gender: true },
});
invalidateUserProfile(access.recordId);
settings = {
overrides,
effective: getAllEffectiveRanges(record, 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;
}
},
);
@@ -0,0 +1,87 @@
import {
apiHandler,
requireGuardianAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import {
getIntegrationStatus,
type IntegrationKey,
} from "@/lib/integrations/status";
import { annotate } from "@/lib/logging/context";
import {
resolveGuardianRecordSettingsAccess,
resolveManagedIntegrationState,
} from "@/lib/record-settings";
export const dynamic = "force-dynamic";
const MANAGED_INTEGRATIONS: readonly IntegrationKey[] = [
"withings",
"whoop",
"fitbit",
"nightscout",
"polar",
"oura",
"google-health",
"strava",
];
/**
* A managed profile may disclose whether its existing integrations are
* connected, but this endpoint deliberately has no control or credential
* projection. OAuth and provider state cannot safely carry a record selector.
*/
export const GET = apiHandler(async () => {
const context = await requireGuardianAuth();
const access = resolveGuardianRecordSettingsAccess(context);
if (!access) throw new SharingAccessDeniedError();
const [statuses, withings, whoop, fitbit, googleHealth] = await Promise.all([
Promise.all(
MANAGED_INTEGRATIONS.map((integration) =>
getIntegrationStatus(access.recordId, integration),
),
),
prisma.withingsConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.whoopConnection.findUnique({
where: { userId: access.recordId },
select: { whoopUserId: true },
}),
prisma.fitbitConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.googleHealthConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
]);
const connected: Record<IntegrationKey, boolean> = {
withings: withings !== null,
whoop: whoop?.whoopUserId != null,
fitbit: fitbit !== null,
"google-health": googleHealth !== null,
nightscout: context.user.nightscoutUrlEncrypted !== null,
polar: context.user.polarAccessTokenEncrypted !== null,
oura: context.user.ouraAccessTokenEncrypted !== null,
strava: context.user.stravaAccessTokenEncrypted !== null,
};
const integrations = statuses.map((status) => ({
integration: status.integration,
state: resolveManagedIntegrationState(
status.state,
connected,
status.integration,
),
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
}));
annotate({ action: { name: "record-settings.integrations.get" } });
return apiSuccess({ recordId: access.recordId, integrations });
});
+35
View File
@@ -0,0 +1,35 @@
import {
apiHandler,
requireGuardianAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
resolveGuardianRecordSettingsAccess,
toRecordSettingsDto,
} from "@/lib/record-settings";
export const dynamic = "force-dynamic";
/**
* The active managed record's configuration identity. This is intentionally a
* distinct record DTO rather than another actor field on `/api/auth/me`.
*/
export const GET = apiHandler(async () => {
const context = await requireGuardianAuth();
const access = resolveGuardianRecordSettingsAccess(context);
if (!access) throw new SharingAccessDeniedError();
annotate({ action: { name: "record-settings.get" } });
return apiSuccess(
toRecordSettingsDto({
id: access.recordId,
name: context.user.displayName ?? context.user.username,
locale: context.user.locale,
timezone: context.user.timezone,
recordKind: "managed",
}),
);
});
+8 -1
View File
@@ -39,6 +39,7 @@ import {
type SettingsSectionSlug,
} from "@/components/settings/section-slugs";
import { SettingsShell } from "@/components/settings/settings-shell";
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
/**
* Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
@@ -128,5 +129,11 @@ export default async function SettingsSectionPage({ params }: PageProps) {
<SectionPlaceholder slug={section} />
);
return <SettingsShell active={section}>{body}</SettingsShell>;
return (
<SettingsShell active={section}>
<RecordSettingsSectionGate section={section}>
{body}
</RecordSettingsSectionGate>
</SettingsShell>
);
}
+8 -5
View File
@@ -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");
});
});
+10 -3
View File
@@ -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,152 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS } from "@/lib/record-settings";
import { ALL_METRICS } from "@/lib/validations/thresholds";
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const render = (
family: Parameters<typeof ManagedRecordSettingsForm>[0]["family"],
settings: Record<string, unknown>,
locale: (typeof LOCALES)[number] = "en",
) =>
renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<ManagedRecordSettingsForm
disabled={false}
family={family}
onSave={vi.fn()}
saveLabel="Save"
settings={settings}
/>
</I18nProvider>,
);
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");
},
);
it("renders every direct module and threshold metric for a fresh record", () => {
const modules = render("modules", { modulePreferences: {} });
const thresholds = render("thresholds", {
overrides: {},
effective: Object.fromEntries(
ALL_METRICS.map((metric) => [
metric,
{ range: { greenMin: 10, greenMax: 20 } },
]),
),
});
for (const module of Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)) {
expect(modules).toContain(`name="${module}"`);
}
for (const metric of ALL_METRICS) {
expect(thresholds).toContain(`value="${metric}"`);
}
expect(thresholds).toContain('value="10"');
expect(thresholds).toContain('value="20"');
});
it("uses localized display inventories instead of raw Coach and Insight IDs", () => {
for (const locale of LOCALES) {
const coach = render(
"coach",
{
preferences: {
excludeMetrics: ["bp"],
dataClusters: ["cardio"],
},
},
locale,
);
const insights = render(
"insights",
{
layout: {
version: 2,
sections: [{ id: "unknown-section", visible: true, order: 0 }],
tiles: [{ id: "unknown-tile", visible: true, order: 0 }],
},
},
locale,
);
expect(coach).not.toContain(">bp<");
expect(coach).not.toContain(">cardio<");
expect(insights).not.toContain(">unknown-section<");
expect(insights).not.toContain(">unknown-tile<");
expect(insights).not.toContain("settings.sharedRecord");
}
});
it.each(["toString", "constructor", "__proto__"])(
"renders the unavailable label for prototype property %s",
(id) => {
const coach = render("coach", {
preferences: {
excludeMetrics: [id],
dataClusters: [id],
},
});
const insights = render("insights", {
layout: {
version: 2,
sections: [{ id, visible: true, order: 0 }],
tiles: [{ id, visible: true, order: 0 }],
},
});
expect(coach).not.toContain(`>${id}<`);
expect(insights).toContain("Unavailable insight");
expect(insights).not.toContain(`>${id}<`);
expect(insights).not.toContain("settings.sharedRecord");
},
);
});
@@ -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");
});
});
@@ -0,0 +1,125 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Plug } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { SettingsCard } from "@/components/settings/settings-card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
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";
interface ManagedIntegrationStatusResponse {
recordId: string;
integrations: Array<{
integration: keyof typeof INTEGRATION_DISPLAY_NAMES;
state: ManagedIntegrationState;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
}>;
}
const INTEGRATION_DISPLAY_NAMES = {
withings: "Withings",
whoop: "WHOOP",
fitbit: "Fitbit",
nightscout: "Nightscout",
polar: "Polar",
oura: "Oura",
"google-health": "Google Health",
strava: "Strava",
} as const;
function recordStatusPillState(state: ManagedIntegrationState) {
switch (state) {
case "connected":
return "connected" as const;
case "error_transient":
return "warning" as const;
case "error_reauth":
return "error" as const;
case "parked":
return "parked" as const;
case "disconnected":
return "disconnected" as const;
}
}
/**
* The managed-record integration view is deliberately a status projection.
* It reads the active record's id from the resolved account-access payload and
* has no connection, credential, sync, or provider-control affordance.
*/
export function ManagedIntegrationStatus() {
const { t } = useTranslations();
const { user } = useAuth();
const activeRecord = user?.accountAccess?.active ?? null;
const recordId = activeRecord?.accountId ?? null;
const statusQuery = useQuery({
queryKey: queryKeys.recordSettingsIntegrations(recordId ?? ""),
queryFn: async () => {
const response = await apiGet<ManagedIntegrationStatusResponse>(
"/api/record-settings/integrations",
);
assertRecordSettingsResponseForRecord(response, recordId);
return response;
},
enabled: activeRecord?.recordKind === "managed" && recordId !== null,
});
if (statusQuery.isError) {
return <QueryErrorCard onRetry={() => void statusQuery.refetch()} />;
}
if (statusQuery.isLoading || !statusQuery.data) {
return (
<SettingsCard aria-busy="true" aria-live="polite">
<SettingsCardHeader
icon={Plug}
title={t("settings.sections.integrations.title")}
description={t("settings.sharedRecord.integrationStatusDescription")}
/>
</SettingsCard>
);
}
return (
<SettingsCard aria-labelledby="managed-integration-status-title">
<SettingsCardHeader
icon={Plug}
title={t("settings.sections.integrations.title")}
titleId="managed-integration-status-title"
description={t("settings.sharedRecord.integrationStatusDescription")}
/>
<ul
className="mt-4 space-y-3"
aria-label={activeRecord?.displayName ?? undefined}
>
{statusQuery.data.integrations.map((integration) => (
<li
key={integration.integration}
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{INTEGRATION_DISPLAY_NAMES[integration.integration]}
</span>
<IntegrationStatusPill
state={recordStatusPillState(integration.state)}
lastSyncAt={
integration.lastSuccessAt ?? integration.lastAttemptAt
}
testId={`managed-integration-${integration.integration}`}
/>
</li>
))}
</ul>
</SettingsCard>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,86 @@
"use client";
import type { ReactNode } from "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
* status projection; all other direct links resolve to a local refusal state.
*/
export function RecordSettingsSectionGate({
section,
children,
}: {
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);
if (
recordKind === "managed" &&
destination.kind === "managed-guardian" &&
section === "integrations"
) {
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
icon={CircleSlash}
title={t("settings.sharedRecord.unavailableTitle")}
titleId="shared-record-settings-unavailable-title"
description={t("settings.sharedRecord.unavailableDescription")}
/>
</SettingsCard>
);
}
+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),
);
@@ -0,0 +1,94 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings/access";
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);
describe("record settings authorization", () => {
it("keeps actor and record separation", async () => {
const [authMeRoute, recordSettingsRoute] = await Promise.all([
readFile(fromRoot("src/app/api/auth/me/route.ts"), "utf8"),
readFile(fromRoot("src/app/api/record-settings/route.ts"), "utf8"),
]);
const access = resolveGuardianRecordSettingsAccess({
actor: { id: "guardian-1" },
grantId: "grant-1",
user: { id: "managed-record-1", managedProfileAt: new Date() },
});
expect(access).toMatchObject({
actorId: "guardian-1",
recordId: "managed-record-1",
recordKind: "managed",
relationship: "guardian",
});
expect(authMeRoute).toContain("requireAuth");
expect(authMeRoute).not.toContain("requireGuardianAuth");
expect(authMeRoute).not.toContain("requireRecordAuth");
expect(recordSettingsRoute).toContain("requireGuardianAuth");
expect(recordSettingsRoute).toContain("toRecordSettingsDto");
});
it("keeps settings classification exhaustive and fail closed", () => {
expect(Object.keys(SETTINGS_DESTINATION_INVENTORY)).not.toHaveLength(0);
expect(classifySettingsDestination("integrations")).toMatchObject({
kind: "managed-guardian",
guardianWritable: false,
});
expect(classifySettingsDestination("ai").kind).toBe("unavailable");
expect(classifySettingsDestination("unknown-settings-path").kind).toBe(
"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:");
expect(route).toContain("invalidateUserMedications(access.recordId, {");
expect(route).toContain("invalidateUserHealthScore(access.recordId)");
expect(route).toContain("getAllEffectiveRanges(record, overrides)");
expect(route).toContain(
"effective: getAllEffectiveRanges(record, overrides)",
);
expect(route).toContain("invalidateUserProfile(access.recordId)");
expect(
route.lastIndexOf("invalidateUserProfile(access.recordId)"),
).toBeGreaterThan(
route.indexOf("data: { thresholdsJson: toJson(overrides) }"),
);
});
});