diff --git a/messages/de.json b/messages/de.json index 388c99a38..6d8c53f1e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 48f404d69..a0e7bc356 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index bbd66c8fa..625c3daff 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 9f68a3ef5..bc662c21a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index c37fdb228..2b0202731 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index e97a5d6ea..526608239 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/record-settings-configuration.test.ts b/src/__tests__/record-settings-configuration.test.ts new file mode 100644 index 000000000..f967fa668 --- /dev/null +++ b/src/__tests__/record-settings-configuration.test.ts @@ -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); + }, + ); +}); diff --git a/src/__tests__/record-settings-contract.test.ts b/src/__tests__/record-settings-contract.test.ts new file mode 100644 index 000000000..5173a575e --- /dev/null +++ b/src/__tests__/record-settings-contract.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/record-settings-route-guard.test.ts b/src/__tests__/record-settings-route-guard.test.ts new file mode 100644 index 000000000..6435fadb1 --- /dev/null +++ b/src/__tests__/record-settings-route-guard.test.ts @@ -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")); + }); +}); diff --git a/src/__tests__/record-settings-ui.test.tsx b/src/__tests__/record-settings-ui.test.tsx new file mode 100644 index 000000000..5d9e17ef3 --- /dev/null +++ b/src/__tests__/record-settings-ui.test.tsx @@ -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; + const actualRecord = actual as Record; + 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(" = { * 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 = {}; +const GUARDIAN_ROUTES: Record = { + "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 = { * 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` — diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts new file mode 100644 index 000000000..16399acc1 --- /dev/null +++ b/src/app/api/record-settings/[family]/route.ts @@ -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; + } + }, +); diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts new file mode 100644 index 000000000..295e24733 --- /dev/null +++ b/src/app/api/record-settings/integrations/route.ts @@ -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 = { + 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 }); +}); diff --git a/src/app/api/record-settings/route.ts b/src/app/api/record-settings/route.ts new file mode 100644 index 000000000..9c18b08e8 --- /dev/null +++ b/src/app/api/record-settings/route.ts @@ -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", + }), + ); +}); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 4df2a7c55..ee1f341af 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -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) { ); - return {body}; + return ( + + + {body} + + + ); } diff --git a/src/app/settings/layout/[module]/page.tsx b/src/app/settings/layout/[module]/page.tsx index ef0f6ad9a..663f756e5 100644 --- a/src/app/settings/layout/[module]/page.tsx +++ b/src/app/settings/layout/[module]/page.tsx @@ -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, }} > -
- - - -
+ +
+ + + +
+
); } diff --git a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx index 7e05b31b5..93d4c7d01 100644 --- a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx +++ b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx @@ -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(" — 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"); + }); }); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index a16a22a86..ae67180d7 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -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 (
diff --git a/src/components/settings/__tests__/managed-record-settings-section.test.tsx b/src/components/settings/__tests__/managed-record-settings-section.test.tsx new file mode 100644 index 000000000..070f82ff1 --- /dev/null +++ b/src/components/settings/__tests__/managed-record-settings-section.test.tsx @@ -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[0]["family"], + settings: Record, + locale: (typeof LOCALES)[number] = "en", +) => + renderToStaticMarkup( + + + , + ); + +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(" { + 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"); + }, + ); +}); diff --git a/src/components/settings/__tests__/record-settings-section-gate.test.tsx b/src/components/settings/__tests__/record-settings-section-gate.test.tsx new file mode 100644 index 000000000..63045a08e --- /dev/null +++ b/src/components/settings/__tests__/record-settings-section-gate.test.tsx @@ -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: () =>
, +})); +vi.mock("../managed-integration-status", () => ({ + ManagedIntegrationStatus: () =>
, +})); + +import { RecordSettingsSectionGate } from "../record-settings-section-gate"; + +function render(section: "modules" | "integrations") { + return renderToStaticMarkup( + + +
+ + , + ); +} + +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"); + }); +}); diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx new file mode 100644 index 000000000..fe94431e5 --- /dev/null +++ b/src/components/settings/managed-integration-status.tsx @@ -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( + "/api/record-settings/integrations", + ); + assertRecordSettingsResponseForRecord(response, recordId); + return response; + }, + enabled: activeRecord?.recordKind === "managed" && recordId !== null, + }); + + if (statusQuery.isError) { + return void statusQuery.refetch()} />; + } + + if (statusQuery.isLoading || !statusQuery.data) { + return ( + + + + ); + } + + return ( + + +
    + {statusQuery.data.integrations.map((integration) => ( +
  • + + {INTEGRATION_DISPLAY_NAMES[integration.integration]} + + +
  • + ))} +
+
+ ); +} diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx new file mode 100644 index 000000000..76b30e62b --- /dev/null +++ b/src/components/settings/managed-record-settings-section.tsx @@ -0,0 +1,1018 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Settings } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { + normalizeInsightsTileId, + type InsightsSectionId, +} from "@/lib/insights-layout"; +import { MODULE_REGISTRY } from "@/lib/modules/registry"; +import { queryKeys } from "@/lib/query-keys"; +import { + assertRecordSettingsResponseForRecord, + MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS, + safeParseManagedRecordSettingsPatch, + type ManagedRecordSettingsFamily, +} from "@/lib/record-settings"; +import { + METRIC_BOUNDS, + type ThresholdMetric, +} from "@/lib/analytics/effective-range"; +import { ALL_METRICS } from "@/lib/validations/thresholds"; +import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; +import { Button } from "@/components/ui/button"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { SettingsCardHeader } from "./_card-header"; +import { SettingsCard } from "./settings-card"; + +interface ManagedRecordSettingsResponse { + recordId: string; + family: ManagedRecordSettingsFamily; + settings: Record; +} + +type ManagedSettingsFamily = Exclude< + ManagedRecordSettingsFamily, + "integrations" +>; +type SettingsFormProps = { + settings: Record; + disabled: boolean; + onSave: (patch: unknown) => void; + saveLabel: string; +}; + +const COACH_EXCLUDE_METRICS = [ + "bp", + "weight", + "pulse", + "mood", + "compliance", + "hrv", + "sleep", + "resting_hr", + "steps", + "medications", + "anthropometrics", +] as const; +const COACH_DATA_CLUSTERS = [ + "cardio", + "body", + "activity", + "workouts", + "sleep", + "mood", + "glucose", + "medication", + "mobility", + "environment", +] as const; +const COACH_EXCLUDE_METRIC_LABEL_KEYS: Record< + (typeof COACH_EXCLUDE_METRICS)[number], + string +> = { + bp: "settings.sharedRecord.managedSettings.coach.metrics.bp", + weight: "settings.sharedRecord.managedSettings.coach.metrics.weight", + pulse: "settings.sharedRecord.managedSettings.coach.metrics.pulse", + mood: "settings.sharedRecord.managedSettings.coach.metrics.mood", + compliance: "settings.sharedRecord.managedSettings.coach.metrics.compliance", + hrv: "settings.sharedRecord.managedSettings.coach.metrics.hrv", + sleep: "settings.sharedRecord.managedSettings.coach.metrics.sleep", + resting_hr: "settings.sharedRecord.managedSettings.coach.metrics.restingHr", + steps: "settings.sharedRecord.managedSettings.coach.metrics.steps", + medications: + "settings.sharedRecord.managedSettings.coach.metrics.medications", + anthropometrics: + "settings.sharedRecord.managedSettings.coach.metrics.anthropometrics", +}; +const COACH_DATA_CLUSTER_LABEL_KEYS: Record< + (typeof COACH_DATA_CLUSTERS)[number], + string +> = { + cardio: "settings.sharedRecord.managedSettings.coach.clusters.cardio", + body: "settings.sharedRecord.managedSettings.coach.clusters.body", + activity: "settings.sharedRecord.managedSettings.coach.clusters.activity", + workouts: "settings.sharedRecord.managedSettings.coach.clusters.workouts", + sleep: "settings.sharedRecord.managedSettings.coach.clusters.sleep", + mood: "settings.sharedRecord.managedSettings.coach.clusters.mood", + glucose: "settings.sharedRecord.managedSettings.coach.clusters.glucose", + medication: "settings.sharedRecord.managedSettings.coach.clusters.medication", + mobility: "settings.sharedRecord.managedSettings.coach.clusters.mobility", + environment: + "settings.sharedRecord.managedSettings.coach.clusters.environment", +}; +const INSIGHTS_SECTION_LABEL_KEYS: Record = { + "wellness-scores": + "settings.sharedRecord.managedSettings.insights.sectionLabels.wellnessScores", + "daily-briefing": + "settings.sharedRecord.managedSettings.insights.sectionLabels.dailyBriefing", + vitals: "settings.sharedRecord.managedSettings.insights.sectionLabels.vitals", + trends: "settings.sharedRecord.managedSettings.insights.sectionLabels.trends", + "period-review": + "settings.sharedRecord.managedSettings.insights.sectionLabels.periodReview", + "cycle-summary": + "settings.sharedRecord.managedSettings.insights.sectionLabels.cycleSummary", + signals: + "settings.sharedRecord.managedSettings.insights.sectionLabels.signals", + "rhythm-events": + "settings.sharedRecord.managedSettings.insights.sectionLabels.rhythmEvents", + "health-status": + "settings.sharedRecord.managedSettings.insights.sectionLabels.healthStatus", + breathing: + "settings.sharedRecord.managedSettings.insights.sectionLabels.breathing", + "labs-changes": + "settings.sharedRecord.managedSettings.insights.sectionLabels.labsChanges", + ecg: "settings.sharedRecord.managedSettings.insights.sectionLabels.ecg", +}; +const METRIC_LABEL_KEYS: Record = { + WEIGHT: "thresholds.metricWeight", + BLOOD_PRESSURE_SYS: "thresholds.metricBpSys", + BLOOD_PRESSURE_DIA: "thresholds.metricBpDia", + PULSE: "thresholds.metricPulse", + BODY_FAT: "thresholds.metricBodyFat", + SLEEP_DURATION: "thresholds.metricSleep", + ACTIVITY_STEPS: "thresholds.metricSteps", + BLOOD_GLUCOSE_FASTING: "thresholds.metricGlucoseFasting", + BLOOD_GLUCOSE_POSTPRANDIAL: "thresholds.metricGlucosePostprandial", + BLOOD_GLUCOSE_RANDOM: "thresholds.metricGlucoseRandom", + BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime", + TOTAL_BODY_WATER: "thresholds.metricBodyWater", + BONE_MASS: "thresholds.metricBoneMass", + OXYGEN_SATURATION: "thresholds.metricOxygenSaturation", +}; + +function asRecord(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function formNumber(form: FormData, name: string, fallback: number): number { + const value = Number(form.get(name)); + return Number.isFinite(value) ? value : fallback; +} + +function ownStringValue(inventory: object, id: string): string | undefined { + if (!Object.hasOwn(inventory, id)) return undefined; + const value = Reflect.get(inventory, id); + return typeof value === "string" ? value : undefined; +} + +function coachMetricLabel( + t: ReturnType["t"], + metric: (typeof COACH_EXCLUDE_METRICS)[number], +): string { + const labelKey = ownStringValue(COACH_EXCLUDE_METRIC_LABEL_KEYS, metric); + return labelKey + ? t(labelKey) + : t("settings.sharedRecord.managedSettings.insights.unknownItem"); +} + +function coachClusterLabel( + t: ReturnType["t"], + cluster: (typeof COACH_DATA_CLUSTERS)[number], +): string { + const labelKey = ownStringValue(COACH_DATA_CLUSTER_LABEL_KEYS, cluster); + return labelKey + ? t(labelKey) + : t("settings.sharedRecord.managedSettings.insights.unknownItem"); +} + +function insightItemLabel( + t: ReturnType["t"], + kind: "section" | "tile", + id: string, +): string { + if (kind === "section") { + const labelKey = ownStringValue(INSIGHTS_SECTION_LABEL_KEYS, id); + return labelKey + ? t(labelKey) + : t("settings.sharedRecord.managedSettings.insights.unknownItem"); + } + + const normalizedId = normalizeInsightsTileId(id); + if (normalizedId === "overview") return t("insights.navOverview"); + if (!Object.hasOwn(SUB_PAGE_TABS, normalizedId)) { + return t("settings.sharedRecord.managedSettings.insights.unknownItem"); + } + + const labelKey = + SUB_PAGE_TABS[normalizedId as keyof typeof SUB_PAGE_TABS]?.labelKey; + return typeof labelKey === "string" + ? t(labelKey) + : t("settings.sharedRecord.managedSettings.insights.unknownItem"); +} + +function SaveButton({ disabled, label }: { disabled: boolean; label: string }) { + return ( + + ); +} + +function ProfileSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const { t } = useTranslations(); + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const displayName = asString(form.get("displayName")).trim(); + const heightValue = asString(form.get("heightCm")).trim(); + onSave({ + displayName: displayName || null, + heightCm: heightValue === "" ? null : Number(heightValue), + dateOfBirth: asString(form.get("dateOfBirth")) || null, + gender: asString(form.get("gender")) || null, + locale: asString(form.get("locale")) || null, + timezone: asString(form.get("timezone")), + unitPreference: asString(form.get("unitPreference")), + timeFormat: asString(form.get("timeFormat")), + dateFormat: asString(form.get("dateFormat")), + }); + } + + return ( +
+ + + + + + + + + +
+ +
+
+ ); +} + +function ModulesSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const { t } = useTranslations(); + const preferences = asRecord(settings.modulePreferences); + const entries = Object.entries(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS) as [ + keyof typeof MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS, + boolean, + ][]; + + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + onSave({ + modulePreferences: Object.fromEntries( + entries.map(([key]) => [key, form.get(key) === "on"]), + ), + }); + } + + return ( +
+ {entries.map(([key, defaultEnabled]) => ( + + ))} + + + ); +} + +function NotificationsSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const { t } = useTranslations(); + const preferences = asRecord(settings.notificationPreferences); + const medication = asRecord(preferences.medication); + const mood = asRecord(preferences.mood); + + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + onSave({ + moodReminderEnabled: form.get("moodReminderEnabled") === "on", + notificationPreferences: { + medication: { + lowStockRunwayDays: + form.get("lowStockRunwayDays") === "" + ? null + : formNumber(form, "lowStockRunwayDays", 7), + reorderLeadDays: formNumber(form, "reorderLeadDays", 10), + }, + mood: { reminderHour: formNumber(form, "reminderHour", 22) }, + }, + }); + } + + return ( +
+ + + + +
+ +
+
+ ); +} + +function ThresholdsSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const { t } = useTranslations(); + const overrides = asRecord(settings.overrides); + const effective = asRecord(settings.effective); + const [metric, setMetric] = useState(ALL_METRICS[0]); + const overrideRange = asRecord(overrides[metric]); + const defaultRange = asRecord(asRecord(effective[metric]).range); + const bounds = METRIC_BOUNDS[metric]; + const minimum = asNumber( + overrideRange.min, + asNumber(defaultRange.greenMin, bounds.min), + ); + const maximum = asNumber( + overrideRange.max, + asNumber(defaultRange.greenMax, bounds.max), + ); + + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + onSave({ + overrides: { + [metric]: { + min: formNumber(form, "minimum", minimum), + max: formNumber(form, "maximum", maximum), + }, + }, + }); + } + + return ( +
+ + + +
+ +
+
+ ); +} + +function CoachSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const { t } = useTranslations(); + const preferences = asRecord(settings.preferences); + const excluded = new Set(asStringArray(preferences.excludeMetrics)); + const dataClusters = new Set(asStringArray(preferences.dataClusters)); + + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + onSave({ + disableCoach: form.get("disableCoach") === "on", + preferences: { + tone: asString(form.get("tone"), "warm"), + verbosity: asString(form.get("verbosity"), "default"), + excludeMetrics: COACH_EXCLUDE_METRICS.filter( + (metric) => form.get(`exclude-${metric}`) === "on", + ), + showEvidenceByDefault: form.get("showEvidenceByDefault") === "on", + defaultWindow: asString(form.get("defaultWindow"), "allTime"), + ...(form.get("useCustomClusters") === "on" + ? { + dataClusters: COACH_DATA_CLUSTERS.filter( + (cluster) => form.get(`cluster-${cluster}`) === "on", + ), + } + : {}), + }, + }); + } + + return ( +
+ +
+ + + +
+ +
+ + {t("settings.sharedRecord.managedSettings.coach.excludedMetrics")} + + {COACH_EXCLUDE_METRICS.map((metric) => ( + + ))} +
+
+ + {t("settings.sharedRecord.managedSettings.coach.dataClusters")} + + + {COACH_DATA_CLUSTERS.map((cluster) => ( + + ))} +
+ + + ); +} + +type LayoutItem = { id: string; visible: boolean; order: number }; + +function layoutItems(value: unknown): LayoutItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const item = asRecord(entry); + const id = asString(item.id); + return id + ? [ + { + id, + visible: item.visible === true, + order: asNumber(item.order, 0), + }, + ] + : []; + }); +} + +function InsightsSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const { t } = useTranslations(); + const layout = asRecord(settings.layout); + const sections = layoutItems(layout.sections); + const tiles = layoutItems(layout.tiles); + + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const toPatch = (kind: "section" | "tile", item: LayoutItem) => ({ + id: item.id, + visible: form.get(`${kind}-visible-${item.id}`) === "on", + order: formNumber(form, `${kind}-order-${item.id}`, item.order), + }); + onSave({ + layout: { + version: asNumber(layout.version, 2), + sections: sections.map((section) => toPatch("section", section)), + tiles: tiles.map((tile) => toPatch("tile", tile)), + }, + }); + } + + const renderItems = (kind: "section" | "tile", items: LayoutItem[]) => ( +
+ + {kind === "section" + ? t("settings.sharedRecord.managedSettings.insights.sections") + : t("settings.sharedRecord.managedSettings.insights.tiles")} + + {items.map((item) => ( +
+ + +
+ ))} +
+ ); + + return ( +
+ {renderItems("section", sections)} + {renderItems("tile", tiles)} + + + ); +} + +export function ManagedRecordSettingsForm({ + family, + ...props +}: SettingsFormProps & { family: ManagedSettingsFamily }) { + switch (family) { + case "profile": + return ; + case "modules": + return ; + case "notifications": + return ; + case "thresholds": + return ; + case "coach": + return ; + case "insights": + return ; + } +} + +/** + * Managed settings use typed target-record controls only. The actor's + * Settings components never mount here, so their actor-scoped queries and + * mutations cannot fire while a Guardian administers a profile. + */ +export function ManagedRecordSettingsSection({ + family, +}: { + family: ManagedSettingsFamily; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + const recordId = user?.accountAccess?.active?.accountId ?? null; + const [validationFailed, setValidationFailed] = useState(false); + const query = useQuery({ + queryKey: queryKeys.recordSettingsFamily(recordId ?? "", family), + enabled: recordId !== null, + queryFn: async () => { + const response = await apiGet( + `/api/record-settings/${family}`, + ); + assertRecordSettingsResponseForRecord(response, recordId); + if (response.family !== family) { + throw new Error( + "Managed record settings response did not match its key", + ); + } + return response; + }, + }); + const save = useMutation({ + mutationFn: async (patch: unknown) => { + const response = await apiPatch( + `/api/record-settings/${family}`, + patch, + ); + assertRecordSettingsResponseForRecord(response, recordId); + if (response.family !== family) { + throw new Error( + "Managed record settings response did not match its key", + ); + } + return response; + }, + onSuccess: (response) => { + if (recordId === null) return; + queryClient.setQueryData( + queryKeys.recordSettingsFamily(recordId, family), + response, + ); + }, + }); + + if (query.isError) { + return void query.refetch()} />; + } + + return ( + + + {query.data ? ( + <> + { + const parsed = safeParseManagedRecordSettingsPatch(family, patch); + if (!parsed.success) { + setValidationFailed(true); + return; + } + setValidationFailed(false); + save.mutate(parsed.data); + }} + saveLabel={t("common.save")} + settings={query.data.settings} + /> + {save.isError || validationFailed ? ( +

+ {t("common.networkError")} +

+ ) : null} + + ) : null} +
+ ); +} diff --git a/src/components/settings/record-settings-section-gate.tsx b/src/components/settings/record-settings-section-gate.tsx new file mode 100644 index 000000000..2bfac5803 --- /dev/null +++ b/src/components/settings/record-settings-section-gate.tsx @@ -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 ( + + + + ); + } + + if (!inSharedRecord) return children; + + const destination = classifySettingsDestination(section); + if ( + recordKind === "managed" && + destination.kind === "managed-guardian" && + section === "integrations" + ) { + return ; + } + + 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 ; + } + + return ( + + + + ); +} diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index 61bce8989..41f8c48c3 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -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([ diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 1578ce75d..33cad047b 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -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; } /** diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 0085b1415..7dd9aec45 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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. * diff --git a/src/lib/query-keys/record-settings.ts b/src/lib/query-keys/record-settings.ts new file mode 100644 index 000000000..f69164645 --- /dev/null +++ b/src/lib/query-keys/record-settings.ts @@ -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, +}; diff --git a/src/lib/record-settings/access.ts b/src/lib/record-settings/access.ts new file mode 100644 index 000000000..024f50671 --- /dev/null +++ b/src/lib/record-settings/access.ts @@ -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; +} diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts new file mode 100644 index 000000000..9413fd128 --- /dev/null +++ b/src/lib/record-settings/classification.ts @@ -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` 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 + ); +} diff --git a/src/lib/record-settings/configuration.ts b/src/lib/record-settings/configuration.ts new file mode 100644 index 000000000..db950e695 --- /dev/null +++ b/src/lib/record-settings/configuration.ts @@ -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; + 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>; + +/** Preserve only directly-owned module keys from a legacy persisted blob. */ +export function managedModulePreferencesFrom( + raw: unknown, +): ManagedRecordSettingsPatch["modules"]["modulePreferences"] { + const allowed: Record = { + ...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; +} diff --git a/src/lib/record-settings/dto.ts b/src/lib/record-settings/dto.ts new file mode 100644 index 000000000..678c49c7b --- /dev/null +++ b/src/lib/record-settings/dto.ts @@ -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, + }, + }; +} diff --git a/src/lib/record-settings/index.ts b/src/lib/record-settings/index.ts new file mode 100644 index 000000000..c349f9355 --- /dev/null +++ b/src/lib/record-settings/index.ts @@ -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"; diff --git a/src/lib/record-settings/integrations.ts b/src/lib/record-settings/integrations.ts new file mode 100644 index 000000000..192dd7e7c --- /dev/null +++ b/src/lib/record-settings/integrations.ts @@ -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, + integration: IntegrationKey, +): IntegrationState { + return connected[integration] ? state : "disconnected"; +} diff --git a/src/lib/record-settings/response.ts b/src/lib/record-settings/response.ts new file mode 100644 index 000000000..4098d931f --- /dev/null +++ b/src/lib/record-settings/response.ts @@ -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"); + } +} diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts index 7b9189e29..4ec9e3bf2 100644 --- a/src/lib/validations/modules.ts +++ b/src/lib/validations/modules.ts @@ -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), ); diff --git a/tests/integration/record-settings-authorization.test.ts b/tests/integration/record-settings-authorization.test.ts new file mode 100644 index 000000000..e4f4341d2 --- /dev/null +++ b/tests/integration/record-settings-authorization.test.ts @@ -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) }"), + ); + }); +});