mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge managed record settings
This commit is contained in:
commit
201a24e404
36 files changed
+3712
-14
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4433,6 +4433,119 @@
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"displayName": "Wyświetlana nazwa",
|
||||
"height": "Wzrost (cm)",
|
||||
"dateOfBirth": "Data urodzenia",
|
||||
"gender": "Płeć",
|
||||
"notSpecified": "Nie podano",
|
||||
"male": "Mężczyzna",
|
||||
"female": "Kobieta",
|
||||
"other": "Inna",
|
||||
"locale": "Język",
|
||||
"systemDefault": "Ustawienie systemowe",
|
||||
"timezone": "Strefa czasowa",
|
||||
"units": "Jednostki",
|
||||
"metric": "Metryczne",
|
||||
"imperial": "Imperialne",
|
||||
"timeFormat": "Format czasu",
|
||||
"automatic": "Automatyczny",
|
||||
"hour12": "12-godzinny",
|
||||
"hour24": "24-godzinny",
|
||||
"dateFormat": "Format daty",
|
||||
"dateDmy": "Dzień / miesiąc / rok",
|
||||
"dateMdy": "Miesiąc / dzień / rok",
|
||||
"dateYmd": "Rok / miesiąc / dzień"
|
||||
},
|
||||
"modules": { "title": "Moduły" },
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
"moodReminder": "Włącz przypomnienie nastroju",
|
||||
"reminderHour": "Godzina przypomnienia nastroju",
|
||||
"lowStockRunwayDays": "Dni do niskiego stanu zapasów",
|
||||
"reorderLeadDays": "Wyprzedzenie ponownego zamówienia"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Cele",
|
||||
"metric": "Metryka",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maksimum"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Trener",
|
||||
"disable": "Wyłącz trenera",
|
||||
"tone": "Ton",
|
||||
"warm": "Ciepły",
|
||||
"neutral": "Neutralny",
|
||||
"concise": "Zwięzły",
|
||||
"verbosity": "Szczegółowość",
|
||||
"brief": "Krótka",
|
||||
"default": "Domyślna",
|
||||
"detailed": "Szczegółowa",
|
||||
"defaultWindow": "Domyślny okres",
|
||||
"last7days": "Ostatnie 7 dni",
|
||||
"last30days": "Ostatnie 30 dni",
|
||||
"last90days": "Ostatnie 90 dni",
|
||||
"allTime": "Cały okres",
|
||||
"showEvidence": "Domyślnie pokazuj źródła",
|
||||
"excludedMetrics": "Wykluczone metryki",
|
||||
"dataClusters": "Grupy danych",
|
||||
"customClusters": "Użyj własnego wyboru grup",
|
||||
"metrics": {
|
||||
"bp": "Ciśnienie krwi",
|
||||
"weight": "Masa ciała",
|
||||
"pulse": "Tętno",
|
||||
"mood": "Nastrój",
|
||||
"compliance": "Przestrzeganie przyjmowania leków",
|
||||
"hrv": "Zmienność rytmu serca",
|
||||
"sleep": "Sen",
|
||||
"restingHr": "Tętno spoczynkowe",
|
||||
"steps": "Kroki",
|
||||
"medications": "Leki",
|
||||
"anthropometrics": "Wymiary ciała"
|
||||
},
|
||||
"clusters": {
|
||||
"cardio": "Zdrowie sercowo-naczyniowe",
|
||||
"body": "Skład ciała",
|
||||
"activity": "Aktywność",
|
||||
"workouts": "Treningi",
|
||||
"sleep": "Sen",
|
||||
"mood": "Nastrój",
|
||||
"glucose": "Glukoza",
|
||||
"medication": "Leki",
|
||||
"mobility": "Mobilność",
|
||||
"environment": "Środowisko"
|
||||
}
|
||||
},
|
||||
"insights": {
|
||||
"title": "Wnioski",
|
||||
"sections": "Sekcje przeglądu",
|
||||
"tiles": "Kafelki nawigacji",
|
||||
"order": "Kolejność",
|
||||
"unknownItem": "Niedostępna analiza",
|
||||
"sectionLabels": {
|
||||
"wellnessScores": "Wyniki dobrostanu",
|
||||
"dailyBriefing": "Codzienny przegląd",
|
||||
"vitals": "Parametry życiowe",
|
||||
"trends": "Trendy",
|
||||
"periodReview": "Przegląd okresu",
|
||||
"cycleSummary": "Podsumowanie cyklu",
|
||||
"signals": "Sygnały",
|
||||
"rhythmEvents": "Zdarzenia rytmu",
|
||||
"healthStatus": "Stan zdrowia",
|
||||
"breathing": "Oddychanie",
|
||||
"labsChanges": "Zmiany wyników laboratoryjnych",
|
||||
"ecg": "EKG"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "Niedostępne w tym udostępnionym rekordzie",
|
||||
"unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.",
|
||||
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu. Elementy sterujące połączeniem są niedostępne."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isManagedRecordSettingsFamily,
|
||||
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
managedModulePreferencesFrom,
|
||||
parseManagedRecordSettingsPatch,
|
||||
} from "@/lib/record-settings/configuration";
|
||||
import { WRITABLE_MODULE_KEYS } from "@/lib/validations/modules";
|
||||
|
||||
describe("managed record settings configuration contract", () => {
|
||||
it("enumerates the only target-record fields each settings DTO may write", () => {
|
||||
expect(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST).toEqual({
|
||||
profile: [
|
||||
"displayName",
|
||||
"heightCm",
|
||||
"dateOfBirth",
|
||||
"gender",
|
||||
"locale",
|
||||
"timezone",
|
||||
"unitPreference",
|
||||
"timeFormat",
|
||||
"dateFormat",
|
||||
],
|
||||
modules: ["modulePreferences"],
|
||||
notifications: ["moodReminderEnabled", "notificationPreferences"],
|
||||
thresholds: ["overrides"],
|
||||
coach: ["disableCoach", "preferences"],
|
||||
insights: ["layout"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["profile", { email: "not-allowed@example.test" }],
|
||||
["modules", { role: "ADMIN" }],
|
||||
[
|
||||
"notifications",
|
||||
{
|
||||
notificationPreferences: { medication: { deliveryDefault: "client" } },
|
||||
},
|
||||
],
|
||||
["thresholds", { glucoseUnit: "imperial" }],
|
||||
["coach", { memories: [] }],
|
||||
["insights", { provider: "external" }],
|
||||
] as const)("rejects a disallowed %s field", (family, patch) => {
|
||||
expect(() => parseManagedRecordSettingsPatch(family, patch)).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"profile",
|
||||
{
|
||||
displayName: "Managed profile",
|
||||
heightCm: 120,
|
||||
dateOfBirth: "2016-04-03",
|
||||
gender: "OTHER",
|
||||
locale: "en",
|
||||
timezone: "Europe/Berlin",
|
||||
unitPreference: "metric",
|
||||
timeFormat: "H24",
|
||||
dateFormat: "DMY",
|
||||
},
|
||||
],
|
||||
["modules", { modulePreferences: { mood: false } }],
|
||||
[
|
||||
"notifications",
|
||||
{
|
||||
moodReminderEnabled: true,
|
||||
notificationPreferences: {
|
||||
medication: { lowStockRunwayDays: 7, reorderLeadDays: 10 },
|
||||
mood: { reminderHour: 22 },
|
||||
},
|
||||
},
|
||||
],
|
||||
["thresholds", { overrides: { WEIGHT: { min: 55, max: 80 } } }],
|
||||
[
|
||||
"coach",
|
||||
{
|
||||
disableCoach: false,
|
||||
preferences: {
|
||||
tone: "warm",
|
||||
verbosity: "default",
|
||||
excludeMetrics: [],
|
||||
showEvidenceByDefault: false,
|
||||
defaultWindow: "allTime",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"insights",
|
||||
{
|
||||
layout: {
|
||||
version: 2,
|
||||
tiles: [{ id: "weight", visible: true, order: 0 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const)("accepts a typed %s patch", (family, patch) => {
|
||||
expect(() => parseManagedRecordSettingsPatch(family, patch)).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses the complete canonical module inventory for a fresh record", () => {
|
||||
expect(Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)).toEqual(
|
||||
WRITABLE_MODULE_KEYS,
|
||||
);
|
||||
expect(managedModulePreferencesFrom(null)).toEqual(
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts only the canonical profile gender enum", () => {
|
||||
expect(() =>
|
||||
parseManagedRecordSettingsPatch("profile", { gender: "MALE" }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
parseManagedRecordSettingsPatch("profile", { gender: "UNKNOWN" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])(
|
||||
"rejects inherited family name %s",
|
||||
(family) => {
|
||||
expect(isManagedRecordSettingsFamily(family)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertRecordSettingsAccess,
|
||||
type RecordSettingsAccess,
|
||||
} from "@/lib/record-settings/access";
|
||||
import {
|
||||
classifySettingsDestination,
|
||||
isGuardianSettingsWriteAllowed,
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { toRecordSettingsDto } from "@/lib/record-settings/dto";
|
||||
import { resolveManagedIntegrationState } from "@/lib/record-settings/integrations";
|
||||
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response";
|
||||
import { recordSettingsKeys } from "@/lib/query-keys";
|
||||
|
||||
describe("record settings contract", () => {
|
||||
it("names the target record in both the DTO and cache key", () => {
|
||||
const dto = toRecordSettingsDto({
|
||||
id: "record-1",
|
||||
name: "Managed profile",
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin",
|
||||
recordKind: "managed",
|
||||
});
|
||||
|
||||
expect(dto).toEqual({
|
||||
record: {
|
||||
id: "record-1",
|
||||
displayName: "Managed profile",
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin",
|
||||
kind: "managed",
|
||||
},
|
||||
});
|
||||
expect(recordSettingsKeys.detail("record-1")).toEqual([
|
||||
"record-settings",
|
||||
"record-1",
|
||||
"detail",
|
||||
]);
|
||||
expect(recordSettingsKeys.recordSettingsIntegrations("record-1")).toEqual([
|
||||
"record-settings",
|
||||
"record-1",
|
||||
"integrations",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let an adult MANAGE grant use Guardian-only configuration", () => {
|
||||
const access: RecordSettingsAccess = {
|
||||
actorId: "adult-manager",
|
||||
recordId: "managed-record",
|
||||
recordKind: "managed",
|
||||
relationship: "adult-manager",
|
||||
};
|
||||
|
||||
expect(() => assertRecordSettingsAccess(access, "guardian")).toThrow(
|
||||
"Guardian configuration is unavailable for this record",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies every Settings destination and limits Guardian writes", () => {
|
||||
expect(Object.keys(SETTINGS_DESTINATION_INVENTORY)).not.toHaveLength(0);
|
||||
expect(classifySettingsDestination("integrations")).toMatchObject({
|
||||
kind: "managed-guardian",
|
||||
guardianWritable: false,
|
||||
});
|
||||
expect(classifySettingsDestination("ai").kind).toBe("unavailable");
|
||||
expect(classifySettingsDestination("dashboard").kind).toBe(
|
||||
"adult-shared-unavailable",
|
||||
);
|
||||
expect(isGuardianSettingsWriteAllowed("account")).toBe(true);
|
||||
expect(isGuardianSettingsWriteAllowed("notifications")).toBe(true);
|
||||
expect(isGuardianSettingsWriteAllowed("integrations")).toBe(false);
|
||||
expect(isGuardianSettingsWriteAllowed("ai")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not report a synthetic ledger row as a managed connection", () => {
|
||||
const connected = {
|
||||
withings: false,
|
||||
whoop: false,
|
||||
fitbit: false,
|
||||
nightscout: false,
|
||||
polar: false,
|
||||
oura: false,
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
resolveManagedIntegrationState("connected", connected, "withings"),
|
||||
).toBe("disconnected");
|
||||
expect(
|
||||
resolveManagedIntegrationState(
|
||||
"error_reauth",
|
||||
{ ...connected, oura: true },
|
||||
"oura",
|
||||
),
|
||||
).toBe("error_reauth");
|
||||
});
|
||||
|
||||
it("rejects a late response for another record in a second tab", () => {
|
||||
expect(() =>
|
||||
assertRecordSettingsResponseForRecord(
|
||||
{ recordId: "managed-record-b" },
|
||||
"managed-record-a",
|
||||
),
|
||||
).toThrow("did not match its key");
|
||||
expect(() =>
|
||||
assertRecordSettingsResponseForRecord(
|
||||
{ recordId: "managed-record-a" },
|
||||
"managed-record-a",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST } from "@/lib/record-settings";
|
||||
|
||||
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
const RECORD_SETTINGS_ROUTE_ROOT = fromRoot("src/app/api/record-settings");
|
||||
|
||||
describe("record settings route guard", () => {
|
||||
it("freezes the non-empty guardian-only route inventory", async () => {
|
||||
const discovered = (
|
||||
await readdir(RECORD_SETTINGS_ROUTE_ROOT, {
|
||||
recursive: true,
|
||||
})
|
||||
)
|
||||
.filter((entry) => entry.endsWith("route.ts"))
|
||||
.map((entry) => `app/api/record-settings/${entry}`)
|
||||
.sort();
|
||||
|
||||
expect(discovered).toHaveLength(3);
|
||||
expect(discovered).toEqual([
|
||||
"app/api/record-settings/[family]/route.ts",
|
||||
"app/api/record-settings/integrations/route.ts",
|
||||
"app/api/record-settings/route.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves every record-settings route through the Guardian fence", async () => {
|
||||
const routes = [
|
||||
"route.ts",
|
||||
"integrations/route.ts",
|
||||
"[family]/route.ts",
|
||||
] as const;
|
||||
const sources = await Promise.all(
|
||||
routes.map((route) =>
|
||||
readFile(resolve(RECORD_SETTINGS_ROUTE_ROOT, route), "utf8"),
|
||||
),
|
||||
);
|
||||
|
||||
for (const source of sources) {
|
||||
expect(source).toContain("requireGuardianAuth");
|
||||
expect(source).toContain("resolveGuardianRecordSettingsAccess");
|
||||
}
|
||||
});
|
||||
|
||||
it("makes the field-specific route exhaustive over the approved DTO families", async () => {
|
||||
const source = await readFile(
|
||||
resolve(RECORD_SETTINGS_ROUTE_ROOT, "[family]/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const families = Object.keys(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST);
|
||||
expect(families.length).toBeGreaterThan(0);
|
||||
for (const family of families) {
|
||||
expect(source).toContain(`case \"${family}\"`);
|
||||
}
|
||||
expect(source).toContain("if (!isManagedRecordSettingsFamily(rawFamily))");
|
||||
expect(source).toContain('apiError("Settings family not found", 404)');
|
||||
expect(
|
||||
source.indexOf("isManagedRecordSettingsFamily(rawFamily)"),
|
||||
).toBeLessThan(source.indexOf("const context = await requireGuardianAuth"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
|
||||
if (
|
||||
expected === null ||
|
||||
typeof expected !== "object" ||
|
||||
actual === null ||
|
||||
typeof actual !== "object"
|
||||
) {
|
||||
expect(typeof actual).toBe(typeof expected);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedRecord = expected as Record<string, unknown>;
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
expect(Object.keys(actualRecord).sort()).toEqual(
|
||||
Object.keys(expectedRecord).sort(),
|
||||
);
|
||||
for (const key of Object.keys(expectedRecord)) {
|
||||
expectMatchingMessageKeys(expectedRecord[key], actualRecord[key]);
|
||||
}
|
||||
}
|
||||
|
||||
describe("managed record settings UI", () => {
|
||||
it("gates shared deep links and renders integration status without controls", async () => {
|
||||
const [
|
||||
statusView,
|
||||
managedSettingsView,
|
||||
settingsPage,
|
||||
layoutModulePage,
|
||||
sectionGate,
|
||||
...localeBundles
|
||||
] = await Promise.all([
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-integration-status.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-record-settings-section.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"),
|
||||
readFile(fromRoot("src/app/settings/layout/[module]/page.tsx"), "utf8"),
|
||||
readFile(
|
||||
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
...["de", "en", "es", "fr", "it", "pl"].map((locale) =>
|
||||
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(statusView).toContain("recordSettingsIntegrations");
|
||||
expect(statusView).toContain("/api/record-settings/integrations");
|
||||
expect(statusView).toContain("assertRecordSettingsResponseForRecord");
|
||||
expect(statusView).not.toContain("<Button");
|
||||
expect(statusView).not.toContain("<Link");
|
||||
expect(settingsPage).toContain("RecordSettingsSectionGate");
|
||||
expect(sectionGate).toContain("if (!inSharedRecord) return children");
|
||||
expect(sectionGate).toContain("ManagedIntegrationStatus");
|
||||
expect(sectionGate).toContain("ManagedRecordSettingsSection");
|
||||
expect(sectionGate).toContain("if (isLoading)");
|
||||
expect(layoutModulePage).toContain(
|
||||
'RecordSettingsSectionGate section="layout"',
|
||||
);
|
||||
expect(managedSettingsView).toContain(
|
||||
"safeParseManagedRecordSettingsPatch",
|
||||
);
|
||||
expect(managedSettingsView).toContain("/api/record-settings/${family}");
|
||||
expect(managedSettingsView).not.toContain("/api/auth/");
|
||||
expect(managedSettingsView).not.toContain("/api/user/");
|
||||
expect(managedSettingsView).not.toContain("<textarea");
|
||||
expect(managedSettingsView).not.toContain("Display name");
|
||||
expect(managedSettingsView).not.toContain("Enable mood reminder");
|
||||
const referenceMessages = JSON.parse(localeBundles[0]).settings.sharedRecord
|
||||
.managedSettings;
|
||||
for (const bundle of localeBundles) {
|
||||
const sharedRecord = JSON.parse(bundle).settings.sharedRecord;
|
||||
expect(sharedRecord).toMatchObject({
|
||||
unavailableTitle: expect.any(String),
|
||||
integrationStatusDescription: expect.any(String),
|
||||
managedSettings: {
|
||||
profile: {
|
||||
title: expect.any(String),
|
||||
displayName: expect.any(String),
|
||||
},
|
||||
modules: { title: expect.any(String) },
|
||||
notifications: { title: expect.any(String) },
|
||||
thresholds: { title: expect.any(String) },
|
||||
coach: { title: expect.any(String) },
|
||||
insights: { title: expect.any(String) },
|
||||
},
|
||||
});
|
||||
expectMatchingMessageKeys(
|
||||
referenceMessages,
|
||||
sharedRecord.managedSettings,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1745,7 +1745,14 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
* the auth layer, or "no module reaches it" would be true because the symbol
|
||||
* had been renamed rather than because no module reaches it.
|
||||
*/
|
||||
const GUARDIAN_ROUTES: Record<string, string> = {};
|
||||
const GUARDIAN_ROUTES: Record<string, string> = {
|
||||
"app/api/record-settings/route.ts":
|
||||
"The managed record's settings descriptor; it is refused for an adult record even with MANAGE.",
|
||||
"app/api/record-settings/integrations/route.ts":
|
||||
"Verified integration connection status for the managed record, without any provider control surface.",
|
||||
"app/api/record-settings/[family]/route.ts":
|
||||
"The closed, field-specific managed-record configuration DTOs; identity and credentials remain excluded.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Route modules that deliberately resolve the ACTOR and keep working under a
|
||||
@@ -1784,9 +1791,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* number rather than one per list on purpose: filling either of the new
|
||||
* literals collides here by construction, so two people filling them at once
|
||||
* find out from the merge rather than from production. Import-status polling
|
||||
* adds two record entries and two MANAGE entries: 190 → 194.
|
||||
* adds two record entries and two MANAGE entries: 190 → 194. Managed-record
|
||||
* settings add two guardian-only entries: 194 → 196. Field-specific
|
||||
* managed-record configuration adds one more: 196 → 197.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 194;
|
||||
const FROZEN_ENTRY_COUNT = 197;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
apiHandler,
|
||||
requireGuardianAuth,
|
||||
SharingAccessDeniedError,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
invalidateUserHealthScore,
|
||||
invalidateUserInsightsLayout,
|
||||
invalidateUserMedications,
|
||||
invalidateUserProfile,
|
||||
} from "@/lib/cache/invalidate";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
isManagedRecordSettingsFamily,
|
||||
managedModulePreferencesFrom,
|
||||
parseManagedRecordSettingsPatch,
|
||||
resolveGuardianRecordSettingsAccess,
|
||||
} from "@/lib/record-settings";
|
||||
import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
} from "@/lib/insights-layout";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
import {
|
||||
parseNotificationPrefs,
|
||||
resolveNotificationPrefs,
|
||||
} from "@/lib/validations/notification-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ family: string }> };
|
||||
|
||||
async function resolveManagedRecordSettings(params: RouteParams["params"]) {
|
||||
const { family: rawFamily } = await params;
|
||||
if (!isManagedRecordSettingsFamily(rawFamily)) return null;
|
||||
|
||||
const context = await requireGuardianAuth();
|
||||
const access = resolveGuardianRecordSettingsAccess(context);
|
||||
if (!access) throw new SharingAccessDeniedError();
|
||||
|
||||
return { access, family: rawFamily };
|
||||
}
|
||||
|
||||
/** Each response names the target record and only one field-specific DTO. */
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const resolved = await resolveManagedRecordSettings(params);
|
||||
if (!resolved) return apiError("Settings family not found", 404);
|
||||
const { access, family } = resolved;
|
||||
|
||||
const record = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: {
|
||||
displayName: true,
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
locale: true,
|
||||
timezone: true,
|
||||
unitPreference: true,
|
||||
timeFormat: true,
|
||||
dateFormat: true,
|
||||
modulePreferencesJson: true,
|
||||
moodReminderEnabled: true,
|
||||
notificationPrefs: true,
|
||||
thresholdsJson: true,
|
||||
disableCoach: true,
|
||||
coachPrefsJson: true,
|
||||
insightsLayoutJson: true,
|
||||
},
|
||||
});
|
||||
if (!record) return apiError("Record not found", 404);
|
||||
|
||||
let settings: unknown;
|
||||
switch (family) {
|
||||
case "profile":
|
||||
settings = {
|
||||
displayName: record.displayName,
|
||||
heightCm: record.heightCm,
|
||||
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
gender: record.gender,
|
||||
locale: record.locale,
|
||||
timezone: record.timezone,
|
||||
unitPreference: record.unitPreference,
|
||||
timeFormat: record.timeFormat,
|
||||
dateFormat: record.dateFormat,
|
||||
};
|
||||
break;
|
||||
case "modules":
|
||||
settings = {
|
||||
modulePreferences: managedModulePreferencesFrom(
|
||||
record.modulePreferencesJson,
|
||||
),
|
||||
};
|
||||
break;
|
||||
case "notifications": {
|
||||
const preferences = parseNotificationPrefs(record.notificationPrefs);
|
||||
settings = {
|
||||
moodReminderEnabled: record.moodReminderEnabled,
|
||||
notificationPreferences: {
|
||||
medication: {
|
||||
lowStockRunwayDays: preferences.medication.lowStockRunwayDays,
|
||||
reorderLeadDays: preferences.medication.reorderLeadDays,
|
||||
},
|
||||
mood: { reminderHour: preferences.mood.reminderHour },
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "thresholds": {
|
||||
const overrides = (record.thresholdsJson ??
|
||||
{}) as ThresholdOverridesJson;
|
||||
settings = {
|
||||
overrides,
|
||||
effective: getAllEffectiveRanges(record, overrides),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "coach": {
|
||||
const preferences = parseCoachPrefs(record.coachPrefsJson);
|
||||
settings = {
|
||||
disableCoach: record.disableCoach,
|
||||
preferences: {
|
||||
tone: preferences.tone,
|
||||
verbosity: preferences.verbosity,
|
||||
excludeMetrics: preferences.excludeMetrics,
|
||||
showEvidenceByDefault: preferences.showEvidenceByDefault,
|
||||
defaultWindow: preferences.defaultWindow,
|
||||
...(preferences.dataClusters !== undefined
|
||||
? { dataClusters: preferences.dataClusters }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "insights":
|
||||
settings = { layout: resolveInsightsLayout(record.insightsLayoutJson) };
|
||||
break;
|
||||
}
|
||||
|
||||
annotate({ action: { name: `record-settings.${family}.get` } });
|
||||
return apiSuccess({ recordId: access.recordId, family, settings });
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const resolved = await resolveManagedRecordSettings(params);
|
||||
if (!resolved) return apiError("Settings family not found", 404);
|
||||
const { access, family } = resolved;
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
let settings: unknown;
|
||||
let changed: readonly string[];
|
||||
|
||||
switch (family) {
|
||||
case "profile": {
|
||||
const patch = parseManagedRecordSettingsPatch("profile", body);
|
||||
const data = {
|
||||
...(patch.displayName !== undefined
|
||||
? { displayName: patch.displayName }
|
||||
: {}),
|
||||
...(patch.heightCm !== undefined
|
||||
? { heightCm: patch.heightCm }
|
||||
: {}),
|
||||
...(patch.dateOfBirth !== undefined
|
||||
? {
|
||||
dateOfBirth: patch.dateOfBirth
|
||||
? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
|
||||
: null,
|
||||
}
|
||||
: {}),
|
||||
...(patch.gender !== undefined ? { gender: patch.gender } : {}),
|
||||
...(patch.locale !== undefined ? { locale: patch.locale } : {}),
|
||||
...(patch.timezone !== undefined
|
||||
? { timezone: patch.timezone }
|
||||
: {}),
|
||||
...(patch.unitPreference !== undefined
|
||||
? { unitPreference: patch.unitPreference }
|
||||
: {}),
|
||||
...(patch.timeFormat !== undefined
|
||||
? { timeFormat: patch.timeFormat }
|
||||
: {}),
|
||||
...(patch.dateFormat !== undefined
|
||||
? { dateFormat: patch.dateFormat }
|
||||
: {}),
|
||||
};
|
||||
const record = await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data,
|
||||
select: {
|
||||
displayName: true,
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
locale: true,
|
||||
timezone: true,
|
||||
unitPreference: true,
|
||||
timeFormat: true,
|
||||
dateFormat: true,
|
||||
},
|
||||
});
|
||||
invalidateUserProfile(access.recordId);
|
||||
if (patch.timezone !== undefined) {
|
||||
invalidateUserMedications(access.recordId, { evict: true });
|
||||
}
|
||||
settings = {
|
||||
...record,
|
||||
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
};
|
||||
changed = Object.keys(patch);
|
||||
break;
|
||||
}
|
||||
case "modules": {
|
||||
const patch = parseManagedRecordSettingsPatch("modules", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { modulePreferencesJson: true },
|
||||
});
|
||||
const modulePreferences = {
|
||||
...managedModulePreferencesFrom(current?.modulePreferencesJson),
|
||||
...patch.modulePreferences,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { modulePreferencesJson: toJson(modulePreferences) },
|
||||
});
|
||||
invalidateUserHealthScore(access.recordId);
|
||||
settings = { modulePreferences };
|
||||
changed = Object.keys(patch.modulePreferences);
|
||||
break;
|
||||
}
|
||||
case "notifications": {
|
||||
const patch = parseManagedRecordSettingsPatch("notifications", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
const notificationPreferences = patch.notificationPreferences
|
||||
? resolveNotificationPrefs(
|
||||
current?.notificationPrefs,
|
||||
patch.notificationPreferences,
|
||||
)
|
||||
: undefined;
|
||||
const record = await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: {
|
||||
...(patch.moodReminderEnabled !== undefined
|
||||
? { moodReminderEnabled: patch.moodReminderEnabled }
|
||||
: {}),
|
||||
...(notificationPreferences !== undefined
|
||||
? { notificationPrefs: toJson(notificationPreferences) }
|
||||
: {}),
|
||||
},
|
||||
select: { moodReminderEnabled: true, notificationPrefs: true },
|
||||
});
|
||||
const preferences = parseNotificationPrefs(record.notificationPrefs);
|
||||
settings = {
|
||||
moodReminderEnabled: record.moodReminderEnabled,
|
||||
notificationPreferences: {
|
||||
medication: {
|
||||
lowStockRunwayDays: preferences.medication.lowStockRunwayDays,
|
||||
reorderLeadDays: preferences.medication.reorderLeadDays,
|
||||
},
|
||||
mood: { reminderHour: preferences.mood.reminderHour },
|
||||
},
|
||||
};
|
||||
changed = Object.keys(patch);
|
||||
break;
|
||||
}
|
||||
case "thresholds": {
|
||||
const patch = parseManagedRecordSettingsPatch("thresholds", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { thresholdsJson: true },
|
||||
});
|
||||
const overrides = {
|
||||
...((current?.thresholdsJson ?? {}) as ThresholdOverridesJson),
|
||||
...patch.overrides,
|
||||
};
|
||||
const record = await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { thresholdsJson: toJson(overrides) },
|
||||
select: { heightCm: true, dateOfBirth: true, gender: true },
|
||||
});
|
||||
invalidateUserProfile(access.recordId);
|
||||
settings = {
|
||||
overrides,
|
||||
effective: getAllEffectiveRanges(record, overrides),
|
||||
};
|
||||
changed = Object.keys(patch.overrides);
|
||||
break;
|
||||
}
|
||||
case "coach": {
|
||||
const patch = parseManagedRecordSettingsPatch("coach", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { coachPrefsJson: true },
|
||||
});
|
||||
const nextPreferences = patch.preferences
|
||||
? {
|
||||
...parseCoachPrefs(current?.coachPrefsJson),
|
||||
...patch.preferences,
|
||||
}
|
||||
: undefined;
|
||||
const record = await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: {
|
||||
...(patch.disableCoach !== undefined
|
||||
? { disableCoach: patch.disableCoach }
|
||||
: {}),
|
||||
...(nextPreferences !== undefined
|
||||
? {
|
||||
coachPrefsJson: toJson(nextPreferences),
|
||||
insightsExcludeMetrics: nextPreferences.excludeMetrics,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
select: { disableCoach: true, coachPrefsJson: true },
|
||||
});
|
||||
const preferences = parseCoachPrefs(record.coachPrefsJson);
|
||||
settings = {
|
||||
disableCoach: record.disableCoach,
|
||||
preferences: {
|
||||
tone: preferences.tone,
|
||||
verbosity: preferences.verbosity,
|
||||
excludeMetrics: preferences.excludeMetrics,
|
||||
showEvidenceByDefault: preferences.showEvidenceByDefault,
|
||||
defaultWindow: preferences.defaultWindow,
|
||||
...(preferences.dataClusters !== undefined
|
||||
? { dataClusters: preferences.dataClusters }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
changed = Object.keys(patch);
|
||||
break;
|
||||
}
|
||||
case "insights": {
|
||||
const patch = parseManagedRecordSettingsPatch("insights", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { insightsLayoutJson: true },
|
||||
});
|
||||
const layout = serializeInsightsLayout(
|
||||
patch.layout,
|
||||
resolveInsightsLayout(current?.insightsLayoutJson),
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { insightsLayoutJson: toJson(layout) },
|
||||
});
|
||||
invalidateUserInsightsLayout(access.recordId);
|
||||
settings = { layout };
|
||||
changed = ["layout"];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog(`record-settings.${family}.update`, {
|
||||
userId: access.recordId,
|
||||
actorUserId: access.actorId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { changed },
|
||||
});
|
||||
annotate({
|
||||
action: { name: `record-settings.${family}.update` },
|
||||
meta: { recordId: access.recordId, changed },
|
||||
});
|
||||
return apiSuccess({ recordId: access.recordId, family, settings });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "issues" in error) {
|
||||
return returnAllZodIssues(error as never, 422);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
apiHandler,
|
||||
requireGuardianAuth,
|
||||
SharingAccessDeniedError,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getIntegrationStatus,
|
||||
type IntegrationKey,
|
||||
} from "@/lib/integrations/status";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
resolveGuardianRecordSettingsAccess,
|
||||
resolveManagedIntegrationState,
|
||||
} from "@/lib/record-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MANAGED_INTEGRATIONS: readonly IntegrationKey[] = [
|
||||
"withings",
|
||||
"whoop",
|
||||
"fitbit",
|
||||
"nightscout",
|
||||
"polar",
|
||||
"oura",
|
||||
"google-health",
|
||||
"strava",
|
||||
];
|
||||
|
||||
/**
|
||||
* A managed profile may disclose whether its existing integrations are
|
||||
* connected, but this endpoint deliberately has no control or credential
|
||||
* projection. OAuth and provider state cannot safely carry a record selector.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const context = await requireGuardianAuth();
|
||||
const access = resolveGuardianRecordSettingsAccess(context);
|
||||
if (!access) throw new SharingAccessDeniedError();
|
||||
const [statuses, withings, whoop, fitbit, googleHealth] = await Promise.all([
|
||||
Promise.all(
|
||||
MANAGED_INTEGRATIONS.map((integration) =>
|
||||
getIntegrationStatus(access.recordId, integration),
|
||||
),
|
||||
),
|
||||
prisma.withingsConnection.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.whoopConnection.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { whoopUserId: true },
|
||||
}),
|
||||
prisma.fitbitConnection.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.googleHealthConnection.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
const connected: Record<IntegrationKey, boolean> = {
|
||||
withings: withings !== null,
|
||||
whoop: whoop?.whoopUserId != null,
|
||||
fitbit: fitbit !== null,
|
||||
"google-health": googleHealth !== null,
|
||||
nightscout: context.user.nightscoutUrlEncrypted !== null,
|
||||
polar: context.user.polarAccessTokenEncrypted !== null,
|
||||
oura: context.user.ouraAccessTokenEncrypted !== null,
|
||||
strava: context.user.stravaAccessTokenEncrypted !== null,
|
||||
};
|
||||
const integrations = statuses.map((status) => ({
|
||||
integration: status.integration,
|
||||
state: resolveManagedIntegrationState(
|
||||
status.state,
|
||||
connected,
|
||||
status.integration,
|
||||
),
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
}));
|
||||
|
||||
annotate({ action: { name: "record-settings.integrations.get" } });
|
||||
|
||||
return apiSuccess({ recordId: access.recordId, integrations });
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type SettingsSectionSlug,
|
||||
} from "@/components/settings/section-slugs";
|
||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
|
||||
|
||||
/**
|
||||
* Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
|
||||
@@ -128,5 +129,11 @@ export default async function SettingsSectionPage({ params }: PageProps) {
|
||||
<SectionPlaceholder slug={section} />
|
||||
);
|
||||
|
||||
return <SettingsShell active={section}>{body}</SettingsShell>;
|
||||
return (
|
||||
<SettingsShell active={section}>
|
||||
<RecordSettingsSectionGate section={section}>
|
||||
{body}
|
||||
</RecordSettingsSectionGate>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { LayoutModuleGate } from "@/components/settings/layout-module-gate";
|
||||
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
|
||||
import {
|
||||
LAYOUT_GROUPS,
|
||||
LAYOUT_GROUP_IDS,
|
||||
@@ -68,11 +69,13 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) {
|
||||
headingId,
|
||||
}}
|
||||
>
|
||||
<section aria-labelledby={headingId} className="space-y-6">
|
||||
<LayoutModuleGate moduleGate={group.moduleGate}>
|
||||
<Body />
|
||||
</LayoutModuleGate>
|
||||
</section>
|
||||
<RecordSettingsSectionGate section="layout">
|
||||
<section aria-labelledby={headingId} className="space-y-6">
|
||||
<LayoutModuleGate moduleGate={group.moduleGate}>
|
||||
<Body />
|
||||
</LayoutModuleGate>
|
||||
</section>
|
||||
</RecordSettingsSectionGate>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -126,6 +126,16 @@ const OWNER_WRITE: AccountAccessEntry = {
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
};
|
||||
const MANAGED_GUARDIAN: AccountAccessEntry = {
|
||||
accountId: "managed-record",
|
||||
username: "managed-record",
|
||||
displayName: "Managed profile",
|
||||
access: "write" as const,
|
||||
level: "manage" as const,
|
||||
sections: null,
|
||||
recordKind: "managed" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
function render(access: AccountAccess, pathname = "/"): string {
|
||||
mockAccessRef.value = access;
|
||||
@@ -199,4 +209,11 @@ describe("<AuthShell> — the owner-only doors", () => {
|
||||
expect(html).not.toContain(slot);
|
||||
}
|
||||
});
|
||||
|
||||
it("admits a switched Guardian to the record-aware Settings gate", () => {
|
||||
const html = render(SWITCHED(MANAGED_GUARDIAN), "/settings/integrations");
|
||||
|
||||
expect(html).toContain("sentinel-page");
|
||||
expect(html).not.toContain("sentinel-unavailable");
|
||||
});
|
||||
});
|
||||
@@ -78,14 +78,21 @@ export function AuthShell({
|
||||
// decides what to mount and the surfaces beneath it decide what to offer,
|
||||
// and the two disagreeing is the whole class of defect this hook exists to
|
||||
// close.
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const { inSharedRecord, recordKind } = useRecordCapabilities();
|
||||
// A managed profile has a deliberately small Settings surface of its own.
|
||||
// Let that route family reach its record-aware gate; adult shared records
|
||||
// stay in the generic refusal branch even at MANAGE.
|
||||
const isManagedRecordSettingsPath =
|
||||
recordKind === "managed" && pathname.startsWith("/settings");
|
||||
// v1.36.0 — a page reached while acting on somebody else's record that
|
||||
// sharing does not cover. The nav already drops these entries, so arriving
|
||||
// here means a bookmark or a browser back button; the panel below explains
|
||||
// it instead of letting the page paint a row of 403 error cards. Paint only
|
||||
// — every route behind these pages refuses on its own.
|
||||
const outsideSharedRecord =
|
||||
inSharedRecord && !isDestinationInSharedRecord(pathname);
|
||||
inSharedRecord &&
|
||||
!isDestinationInSharedRecord(pathname) &&
|
||||
!isManagedRecordSettingsPath;
|
||||
const isOnboardingPage = pathname === "/onboarding";
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
@@ -222,7 +229,7 @@ export function AuthShell({
|
||||
// flight: the role is unknown until the payload lands, and mounting
|
||||
// `/admin/*` children early would fire admin queries that 403 for a
|
||||
// non-admin before the redirect effect can move them away.
|
||||
if (isLoading && isAdminPage) {
|
||||
if (isLoading && (isAdminPage || pathname.startsWith("/settings"))) {
|
||||
return (
|
||||
<div className="flex h-dvh items-center justify-center" role="status">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS } from "@/lib/record-settings";
|
||||
import { ALL_METRICS } from "@/lib/validations/thresholds";
|
||||
|
||||
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
|
||||
const render = (
|
||||
family: Parameters<typeof ManagedRecordSettingsForm>[0]["family"],
|
||||
settings: Record<string, unknown>,
|
||||
locale: (typeof LOCALES)[number] = "en",
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ManagedRecordSettingsForm
|
||||
disabled={false}
|
||||
family={family}
|
||||
onSave={vi.fn()}
|
||||
saveLabel="Save"
|
||||
settings={settings}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("managed record settings forms", () => {
|
||||
it.each([
|
||||
["profile", { timezone: "Europe/Berlin" }, "managed-display-name"],
|
||||
["modules", { modulePreferences: { mood: true } }, "mood"],
|
||||
[
|
||||
"notifications",
|
||||
{
|
||||
moodReminderEnabled: true,
|
||||
notificationPreferences: {
|
||||
medication: { lowStockRunwayDays: 7, reorderLeadDays: 10 },
|
||||
mood: { reminderHour: 22 },
|
||||
},
|
||||
},
|
||||
"managed-reminder-hour",
|
||||
],
|
||||
[
|
||||
"thresholds",
|
||||
{ overrides: { WEIGHT: { min: 55, max: 80 } } },
|
||||
"managed-threshold-metric",
|
||||
],
|
||||
[
|
||||
"coach",
|
||||
{ disableCoach: false, preferences: { tone: "warm" } },
|
||||
"managed-coach-tone",
|
||||
],
|
||||
[
|
||||
"insights",
|
||||
{
|
||||
layout: {
|
||||
version: 2,
|
||||
tiles: [{ id: "weight", visible: true, order: 0 }],
|
||||
},
|
||||
},
|
||||
"tile-order-weight",
|
||||
],
|
||||
] as const)(
|
||||
"renders labeled %s controls without an internal JSON editor",
|
||||
(family, settings, control) => {
|
||||
const html = render(family, settings);
|
||||
|
||||
expect(html).toContain(control);
|
||||
expect(html).toContain("Save");
|
||||
expect(html).not.toContain("<textarea");
|
||||
},
|
||||
);
|
||||
|
||||
it("renders every direct module and threshold metric for a fresh record", () => {
|
||||
const modules = render("modules", { modulePreferences: {} });
|
||||
const thresholds = render("thresholds", {
|
||||
overrides: {},
|
||||
effective: Object.fromEntries(
|
||||
ALL_METRICS.map((metric) => [
|
||||
metric,
|
||||
{ range: { greenMin: 10, greenMax: 20 } },
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
for (const module of Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)) {
|
||||
expect(modules).toContain(`name="${module}"`);
|
||||
}
|
||||
for (const metric of ALL_METRICS) {
|
||||
expect(thresholds).toContain(`value="${metric}"`);
|
||||
}
|
||||
expect(thresholds).toContain('value="10"');
|
||||
expect(thresholds).toContain('value="20"');
|
||||
});
|
||||
|
||||
it("uses localized display inventories instead of raw Coach and Insight IDs", () => {
|
||||
for (const locale of LOCALES) {
|
||||
const coach = render(
|
||||
"coach",
|
||||
{
|
||||
preferences: {
|
||||
excludeMetrics: ["bp"],
|
||||
dataClusters: ["cardio"],
|
||||
},
|
||||
},
|
||||
locale,
|
||||
);
|
||||
const insights = render(
|
||||
"insights",
|
||||
{
|
||||
layout: {
|
||||
version: 2,
|
||||
sections: [{ id: "unknown-section", visible: true, order: 0 }],
|
||||
tiles: [{ id: "unknown-tile", visible: true, order: 0 }],
|
||||
},
|
||||
},
|
||||
locale,
|
||||
);
|
||||
|
||||
expect(coach).not.toContain(">bp<");
|
||||
expect(coach).not.toContain(">cardio<");
|
||||
expect(insights).not.toContain(">unknown-section<");
|
||||
expect(insights).not.toContain(">unknown-tile<");
|
||||
expect(insights).not.toContain("settings.sharedRecord");
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["toString", "constructor", "__proto__"])(
|
||||
"renders the unavailable label for prototype property %s",
|
||||
(id) => {
|
||||
const coach = render("coach", {
|
||||
preferences: {
|
||||
excludeMetrics: [id],
|
||||
dataClusters: [id],
|
||||
},
|
||||
});
|
||||
const insights = render("insights", {
|
||||
layout: {
|
||||
version: 2,
|
||||
sections: [{ id, visible: true, order: 0 }],
|
||||
tiles: [{ id, visible: true, order: 0 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(coach).not.toContain(`>${id}<`);
|
||||
expect(insights).toContain("Unavailable insight");
|
||||
expect(insights).not.toContain(`>${id}<`);
|
||||
expect(insights).not.toContain("settings.sharedRecord");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const authRef: {
|
||||
isLoading: boolean;
|
||||
active: {
|
||||
accountId: string;
|
||||
recordKind: "managed" | "shared";
|
||||
level: "manage";
|
||||
sections: null;
|
||||
canWrite: true;
|
||||
} | null;
|
||||
} = {
|
||||
isLoading: true,
|
||||
active: null,
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isLoading: authRef.isLoading,
|
||||
user: authRef.active
|
||||
? {
|
||||
accountAccess: {
|
||||
active: authRef.active,
|
||||
accounts: [authRef.active],
|
||||
canSwitch: true,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../managed-record-settings-section", () => ({
|
||||
ManagedRecordSettingsSection: () => <div data-slot="managed-settings" />,
|
||||
}));
|
||||
vi.mock("../managed-integration-status", () => ({
|
||||
ManagedIntegrationStatus: () => <div data-slot="managed-integrations" />,
|
||||
}));
|
||||
|
||||
import { RecordSettingsSectionGate } from "../record-settings-section-gate";
|
||||
|
||||
function render(section: "modules" | "integrations") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<RecordSettingsSectionGate section={section}>
|
||||
<div data-slot="actor-settings" />
|
||||
</RecordSettingsSectionGate>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RecordSettingsSectionGate", () => {
|
||||
it("does not mount an actor Settings component while auth is unresolved", () => {
|
||||
authRef.isLoading = true;
|
||||
authRef.active = null;
|
||||
|
||||
const html = render("modules");
|
||||
|
||||
expect(html).not.toContain("actor-settings");
|
||||
expect(html).toContain('role="status"');
|
||||
});
|
||||
|
||||
it("uses the record-scoped section under a switched Guardian", () => {
|
||||
authRef.isLoading = false;
|
||||
authRef.active = {
|
||||
accountId: "managed-record",
|
||||
recordKind: "managed",
|
||||
level: "manage",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const html = render("modules");
|
||||
|
||||
expect(html).toContain("managed-settings");
|
||||
expect(html).not.toContain("actor-settings");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plug } from "lucide-react";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings";
|
||||
|
||||
type ManagedIntegrationState =
|
||||
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
|
||||
interface ManagedIntegrationStatusResponse {
|
||||
recordId: string;
|
||||
integrations: Array<{
|
||||
integration: keyof typeof INTEGRATION_DISPLAY_NAMES;
|
||||
state: ManagedIntegrationState;
|
||||
lastSuccessAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
const INTEGRATION_DISPLAY_NAMES = {
|
||||
withings: "Withings",
|
||||
whoop: "WHOOP",
|
||||
fitbit: "Fitbit",
|
||||
nightscout: "Nightscout",
|
||||
polar: "Polar",
|
||||
oura: "Oura",
|
||||
"google-health": "Google Health",
|
||||
strava: "Strava",
|
||||
} as const;
|
||||
|
||||
function recordStatusPillState(state: ManagedIntegrationState) {
|
||||
switch (state) {
|
||||
case "connected":
|
||||
return "connected" as const;
|
||||
case "error_transient":
|
||||
return "warning" as const;
|
||||
case "error_reauth":
|
||||
return "error" as const;
|
||||
case "parked":
|
||||
return "parked" as const;
|
||||
case "disconnected":
|
||||
return "disconnected" as const;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed-record integration view is deliberately a status projection.
|
||||
* It reads the active record's id from the resolved account-access payload and
|
||||
* has no connection, credential, sync, or provider-control affordance.
|
||||
*/
|
||||
export function ManagedIntegrationStatus() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const activeRecord = user?.accountAccess?.active ?? null;
|
||||
const recordId = activeRecord?.accountId ?? null;
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.recordSettingsIntegrations(recordId ?? ""),
|
||||
queryFn: async () => {
|
||||
const response = await apiGet<ManagedIntegrationStatusResponse>(
|
||||
"/api/record-settings/integrations",
|
||||
);
|
||||
assertRecordSettingsResponseForRecord(response, recordId);
|
||||
return response;
|
||||
},
|
||||
enabled: activeRecord?.recordKind === "managed" && recordId !== null,
|
||||
});
|
||||
|
||||
if (statusQuery.isError) {
|
||||
return <QueryErrorCard onRetry={() => void statusQuery.refetch()} />;
|
||||
}
|
||||
|
||||
if (statusQuery.isLoading || !statusQuery.data) {
|
||||
return (
|
||||
<SettingsCard aria-busy="true" aria-live="polite">
|
||||
<SettingsCardHeader
|
||||
icon={Plug}
|
||||
title={t("settings.sections.integrations.title")}
|
||||
description={t("settings.sharedRecord.integrationStatusDescription")}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard aria-labelledby="managed-integration-status-title">
|
||||
<SettingsCardHeader
|
||||
icon={Plug}
|
||||
title={t("settings.sections.integrations.title")}
|
||||
titleId="managed-integration-status-title"
|
||||
description={t("settings.sharedRecord.integrationStatusDescription")}
|
||||
/>
|
||||
<ul
|
||||
className="mt-4 space-y-3"
|
||||
aria-label={activeRecord?.displayName ?? undefined}
|
||||
>
|
||||
{statusQuery.data.integrations.map((integration) => (
|
||||
<li
|
||||
key={integration.integration}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{INTEGRATION_DISPLAY_NAMES[integration.integration]}
|
||||
</span>
|
||||
<IntegrationStatusPill
|
||||
state={recordStatusPillState(integration.state)}
|
||||
lastSyncAt={
|
||||
integration.lastSuccessAt ?? integration.lastAttemptAt
|
||||
}
|
||||
testId={`managed-integration-${integration.integration}`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { CircleSlash, Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ManagedIntegrationStatus } from "@/components/settings/managed-integration-status";
|
||||
import { ManagedRecordSettingsSection } from "@/components/settings/managed-record-settings-section";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
const MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION = {
|
||||
account: "profile",
|
||||
modules: "modules",
|
||||
notifications: "notifications",
|
||||
insights: "insights",
|
||||
thresholds: "thresholds",
|
||||
coach: "coach",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Stops actor-scoped Settings components from mounting while the browser is
|
||||
* switched into another record. The only managed integration surface is its
|
||||
* status projection; all other direct links resolve to a local refusal state.
|
||||
*/
|
||||
export function RecordSettingsSectionGate({
|
||||
section,
|
||||
children,
|
||||
}: {
|
||||
section: SettingsSectionSlug;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { isLoading } = useAuth();
|
||||
const { inSharedRecord, recordKind } = useRecordCapabilities();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// A shared session must never begin by mounting the actor's Settings
|
||||
// component and withdraw it when `/api/auth/me` resolves. The shell holds
|
||||
// Settings during auth resolution too; this branch keeps direct route use
|
||||
// equally safe when the gate is rendered independently in a test or story.
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsCard aria-busy="true" role="status">
|
||||
<SettingsCardHeader icon={Loader2} title={t("nav.loadingScreen")} />
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!inSharedRecord) return children;
|
||||
|
||||
const destination = classifySettingsDestination(section);
|
||||
if (
|
||||
recordKind === "managed" &&
|
||||
destination.kind === "managed-guardian" &&
|
||||
section === "integrations"
|
||||
) {
|
||||
return <ManagedIntegrationStatus />;
|
||||
}
|
||||
|
||||
if (
|
||||
recordKind === "managed" &&
|
||||
destination.kind === "managed-guardian" &&
|
||||
section in MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION
|
||||
) {
|
||||
const family =
|
||||
MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION[
|
||||
section as keyof typeof MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION
|
||||
];
|
||||
return <ManagedRecordSettingsSection family={family} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard aria-labelledby="shared-record-settings-unavailable-title">
|
||||
<SettingsCardHeader
|
||||
icon={CircleSlash}
|
||||
title={t("settings.sharedRecord.unavailableTitle")}
|
||||
titleId="shared-record-settings-unavailable-title"
|
||||
description={t("settings.sharedRecord.unavailableDescription")}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,20 @@ import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
orderedVisibleSectionIds,
|
||||
normalizeInsightsTileId,
|
||||
resolveTileLayout,
|
||||
} from "@/lib/insights-layout";
|
||||
|
||||
describe("normalizeInsightsTileId", () => {
|
||||
it("only resolves own legacy aliases", () => {
|
||||
expect(normalizeInsightsTileId("blutdruck")).toBe("blood-pressure");
|
||||
|
||||
for (const id of ["toString", "constructor", "__proto__"]) {
|
||||
expect(normalizeInsightsTileId(id)).toBe(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("insights-layout v2 — section id universe", () => {
|
||||
it("carries exactly the default sections in render order", () => {
|
||||
expect([...INSIGHTS_SECTION_IDS]).toEqual([
|
||||
|
||||
@@ -87,7 +87,9 @@ export const ACCEPTED_INSIGHTS_TILE_IDS = [
|
||||
* resolver's known-id filter drops anything truly unrecognised.
|
||||
*/
|
||||
export function normalizeInsightsTileId(id: string): string {
|
||||
return LEGACY_INSIGHTS_TILE_ID_ALIASES[id] ?? id;
|
||||
return Object.hasOwn(LEGACY_INSIGHTS_TILE_ID_ALIASES, id)
|
||||
? LEGACY_INSIGHTS_TILE_ID_ALIASES[id]
|
||||
: id;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,7 @@ import { medicationKeys } from "./medications";
|
||||
import { mentalHealthKeys } from "./mental-health";
|
||||
import { moodKeys } from "./mood";
|
||||
import { nutrientKeys } from "./nutrients";
|
||||
import { recordSettingsKeys } from "./record-settings";
|
||||
import { settingsKeys } from "./settings";
|
||||
import { sharingKeys } from "./sharing";
|
||||
import { workoutKeys } from "./workouts";
|
||||
@@ -63,9 +64,12 @@ export const queryKeys = {
|
||||
...documentKeys,
|
||||
...customMetrics,
|
||||
...nutrientKeys,
|
||||
...recordSettingsKeys,
|
||||
...sharingKeys,
|
||||
};
|
||||
|
||||
export { recordSettingsKeys } from "./record-settings";
|
||||
|
||||
/**
|
||||
* v1.36.0 — every read a grant transition can make stale.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Record configuration has its own cache family because it follows the active
|
||||
* health record, while `authMe()` remains the person at the keyboard. Keeping
|
||||
* the record id in every member prevents an actor cache cell from being read
|
||||
* or invalidated as though it belonged to a switched record.
|
||||
*/
|
||||
export const recordSettingsKeys = {
|
||||
root: () => ["record-settings"] as const,
|
||||
detail: (recordId: string) =>
|
||||
["record-settings", recordId, "detail"] as const,
|
||||
integrations: (recordId: string) =>
|
||||
["record-settings", recordId, "integrations"] as const,
|
||||
profile: (recordId: string) =>
|
||||
["record-settings", recordId, "profile"] as const,
|
||||
family: (recordId: string, family: string) =>
|
||||
["record-settings", recordId, family] as const,
|
||||
/** Named aliases stay unambiguous when assembled into `queryKeys`. */
|
||||
recordSettingsDetail: (recordId: string) =>
|
||||
["record-settings", recordId, "detail"] as const,
|
||||
recordSettingsIntegrations: (recordId: string) =>
|
||||
["record-settings", recordId, "integrations"] as const,
|
||||
recordSettingsProfile: (recordId: string) =>
|
||||
["record-settings", recordId, "profile"] as const,
|
||||
recordSettingsFamily: (recordId: string, family: string) =>
|
||||
["record-settings", recordId, family] as const,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
export type RecordSettingsRelationship = "guardian" | "self" | "adult-manager";
|
||||
|
||||
export interface RecordSettingsAccess {
|
||||
actorId: string;
|
||||
recordId: string;
|
||||
recordKind: "managed" | "self" | "shared";
|
||||
relationship: RecordSettingsRelationship;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a record-settings capability that is deliberately narrower than a
|
||||
* general MANAGE grant. A MANAGE grant on an adult's ordinary record is not a
|
||||
* route into their identity or configuration.
|
||||
*/
|
||||
export function assertRecordSettingsAccess(
|
||||
access: RecordSettingsAccess,
|
||||
destination: "guardian",
|
||||
): void {
|
||||
if (
|
||||
destination === "guardian" &&
|
||||
(access.recordKind !== "managed" || access.relationship !== "guardian")
|
||||
) {
|
||||
throw new Error("Guardian configuration is unavailable for this record");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the resolver's authenticated record into the explicit settings
|
||||
* boundary. The resolver authenticates the active grant; this function keeps
|
||||
* the managed-record marker and relationship requirement visible at every
|
||||
* record-settings route.
|
||||
*/
|
||||
export function resolveGuardianRecordSettingsAccess(context: {
|
||||
actor: { id: string };
|
||||
grantId: string | null;
|
||||
user: { id: string; managedProfileAt: Date | null };
|
||||
}): RecordSettingsAccess | null {
|
||||
const access: RecordSettingsAccess = {
|
||||
actorId: context.actor.id,
|
||||
recordId: context.user.id,
|
||||
recordKind: context.user.managedProfileAt ? "managed" : "self",
|
||||
relationship: context.grantId ? "guardian" : "self",
|
||||
};
|
||||
|
||||
try {
|
||||
assertRecordSettingsAccess(access, "guardian");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return access;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
export type SettingsDestinationKind =
|
||||
"personal" | "managed-guardian" | "adult-shared-unavailable" | "unavailable";
|
||||
|
||||
export interface SettingsDestinationClassification {
|
||||
kind: SettingsDestinationKind;
|
||||
guardianWritable: boolean;
|
||||
}
|
||||
|
||||
const PERSONAL: SettingsDestinationClassification = {
|
||||
kind: "personal",
|
||||
guardianWritable: false,
|
||||
};
|
||||
|
||||
const MANAGED_GUARDIAN: SettingsDestinationClassification = {
|
||||
kind: "managed-guardian",
|
||||
guardianWritable: true,
|
||||
};
|
||||
|
||||
const MANAGED_GUARDIAN_STATUS: SettingsDestinationClassification = {
|
||||
kind: "managed-guardian",
|
||||
guardianWritable: false,
|
||||
};
|
||||
|
||||
const ADULT_SHARED_UNAVAILABLE: SettingsDestinationClassification = {
|
||||
kind: "adult-shared-unavailable",
|
||||
guardianWritable: false,
|
||||
};
|
||||
|
||||
const UNAVAILABLE: SettingsDestinationClassification = {
|
||||
kind: "unavailable",
|
||||
guardianWritable: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The complete Settings route inventory. `satisfies Record<SettingsSectionSlug,
|
||||
* ...>` makes adding a new destination a compile error until its record
|
||||
* classification is reviewed. Unknown destinations resolve to unavailable so
|
||||
* direct links fail closed rather than inheriting a neighbouring category.
|
||||
*/
|
||||
export const SETTINGS_DESTINATION_INVENTORY = {
|
||||
account: MANAGED_GUARDIAN,
|
||||
security: PERSONAL,
|
||||
access: PERSONAL,
|
||||
modules: MANAGED_GUARDIAN,
|
||||
integrations: MANAGED_GUARDIAN_STATUS,
|
||||
sources: UNAVAILABLE,
|
||||
notifications: MANAGED_GUARDIAN,
|
||||
layout: ADULT_SHARED_UNAVAILABLE,
|
||||
dashboard: ADULT_SHARED_UNAVAILABLE,
|
||||
insights: MANAGED_GUARDIAN,
|
||||
medications: ADULT_SHARED_UNAVAILABLE,
|
||||
mood: ADULT_SHARED_UNAVAILABLE,
|
||||
labs: ADULT_SHARED_UNAVAILABLE,
|
||||
illness: ADULT_SHARED_UNAVAILABLE,
|
||||
environment: UNAVAILABLE,
|
||||
anamnesis: ADULT_SHARED_UNAVAILABLE,
|
||||
score: ADULT_SHARED_UNAVAILABLE,
|
||||
vorsorge: ADULT_SHARED_UNAVAILABLE,
|
||||
thresholds: MANAGED_GUARDIAN,
|
||||
ai: UNAVAILABLE,
|
||||
coach: MANAGED_GUARDIAN,
|
||||
api: PERSONAL,
|
||||
mcp: PERSONAL,
|
||||
gesundheitsakte: PERSONAL,
|
||||
sharing: UNAVAILABLE,
|
||||
export: PERSONAL,
|
||||
advanced: PERSONAL,
|
||||
privacy: PERSONAL,
|
||||
about: PERSONAL,
|
||||
} as const satisfies Record<
|
||||
SettingsSectionSlug,
|
||||
SettingsDestinationClassification
|
||||
>;
|
||||
|
||||
export function classifySettingsDestination(
|
||||
destination: string,
|
||||
): SettingsDestinationClassification {
|
||||
return (
|
||||
SETTINGS_DESTINATION_INVENTORY[destination as SettingsSectionSlug] ??
|
||||
UNAVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
export function isGuardianSettingsWriteAllowed(destination: string): boolean {
|
||||
const classification = classifySettingsDestination(destination);
|
||||
return (
|
||||
classification.kind === "managed-guardian" &&
|
||||
classification.guardianWritable
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
ACCEPTED_INSIGHTS_TILE_IDS,
|
||||
INSIGHTS_SECTION_IDS,
|
||||
} from "@/lib/insights-layout";
|
||||
import { MODULE_REGISTRY } from "@/lib/modules/registry";
|
||||
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
|
||||
import {
|
||||
modulePrefsPatchSchema,
|
||||
WRITABLE_MODULE_KEYS,
|
||||
} from "@/lib/validations/modules";
|
||||
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
|
||||
|
||||
/**
|
||||
* The target-record configuration boundary. These literals are both the DTO
|
||||
* contract and the reviewable answer to which fields a Guardian may change.
|
||||
* Identity, credentials, delivery channels, memories, provider connections,
|
||||
* and every future field fail closed because no family accepts them.
|
||||
*/
|
||||
export const MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST = {
|
||||
profile: [
|
||||
"displayName",
|
||||
"heightCm",
|
||||
"dateOfBirth",
|
||||
"gender",
|
||||
"locale",
|
||||
"timezone",
|
||||
"unitPreference",
|
||||
"timeFormat",
|
||||
"dateFormat",
|
||||
],
|
||||
modules: ["modulePreferences"],
|
||||
notifications: ["moodReminderEnabled", "notificationPreferences"],
|
||||
thresholds: ["overrides"],
|
||||
coach: ["disableCoach", "preferences"],
|
||||
insights: ["layout"],
|
||||
} as const;
|
||||
|
||||
export type ManagedRecordSettingsFamily =
|
||||
keyof typeof MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST;
|
||||
|
||||
const timezoneSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine((timezone) => {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Expected an IANA timezone");
|
||||
|
||||
const profilePatchSchema = z
|
||||
.object({
|
||||
displayName: z.string().trim().min(1).max(100).nullable().optional(),
|
||||
heightCm: z.number().finite().min(30).max(300).nullable().optional(),
|
||||
dateOfBirth: z.string().date().nullable().optional(),
|
||||
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable().optional(),
|
||||
timezone: timezoneSchema.optional(),
|
||||
unitPreference: z.enum(["metric", "imperial"]).optional(),
|
||||
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
|
||||
dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const insightsLayoutSchema = z
|
||||
.object({
|
||||
version: z.union([z.literal(1), z.literal(2)]),
|
||||
sections: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.enum(INSIGHTS_SECTION_IDS),
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.max(50)
|
||||
.optional(),
|
||||
tiles: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.enum(ACCEPTED_INSIGHTS_TILE_IDS),
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(1)
|
||||
.max(ACCEPTED_INSIGHTS_TILE_IDS.length)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Delivery ownership is the actor's personal channel choice. A Guardian may
|
||||
// set only the record's reminder timing and low-stock thresholds, never hand
|
||||
// a managed record to an actor's phone or provider channel.
|
||||
const managedNotificationPreferencesSchema = z
|
||||
.object({
|
||||
medication: z
|
||||
.object({
|
||||
lowStockRunwayDays: z.number().int().min(1).max(60).nullable(),
|
||||
reorderLeadDays: z.number().int().min(0).max(60),
|
||||
})
|
||||
.strict()
|
||||
.partial()
|
||||
.optional(),
|
||||
mood: z
|
||||
.object({ reminderHour: z.number().int().min(0).max(23) })
|
||||
.strict()
|
||||
.partial()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const managedCoachPreferencesSchema = coachPrefsSchema
|
||||
.pick({
|
||||
tone: true,
|
||||
verbosity: true,
|
||||
excludeMetrics: true,
|
||||
showEvidenceByDefault: true,
|
||||
defaultWindow: true,
|
||||
dataClusters: true,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS = {
|
||||
profile: profilePatchSchema,
|
||||
modules: z.object({ modulePreferences: modulePrefsPatchSchema }).strict(),
|
||||
notifications: z
|
||||
.object({
|
||||
moodReminderEnabled: z.boolean().optional(),
|
||||
notificationPreferences: managedNotificationPreferencesSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
thresholds: z.object({ overrides: thresholdsUpdateSchema }).strict(),
|
||||
coach: z
|
||||
.object({
|
||||
disableCoach: z.boolean().optional(),
|
||||
preferences: managedCoachPreferencesSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
insights: z.object({ layout: insightsLayoutSchema }).strict(),
|
||||
} as const;
|
||||
|
||||
export type ManagedRecordSettingsPatch = {
|
||||
profile: z.infer<typeof profilePatchSchema>;
|
||||
modules: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["modules"]>;
|
||||
notifications: z.infer<
|
||||
(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["notifications"]
|
||||
>;
|
||||
thresholds: z.infer<
|
||||
(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["thresholds"]
|
||||
>;
|
||||
coach: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["coach"]>;
|
||||
insights: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["insights"]>;
|
||||
};
|
||||
|
||||
export function isManagedRecordSettingsFamily(
|
||||
value: string,
|
||||
): value is ManagedRecordSettingsFamily {
|
||||
return Object.hasOwn(MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS, value);
|
||||
}
|
||||
|
||||
/** Parse one named DTO family. Strict schemas make an actor field a 422. */
|
||||
export function parseManagedRecordSettingsPatch<
|
||||
Family extends ManagedRecordSettingsFamily,
|
||||
>(family: Family, value: unknown): ManagedRecordSettingsPatch[Family] {
|
||||
return MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS[family].parse(
|
||||
value,
|
||||
) as ManagedRecordSettingsPatch[Family];
|
||||
}
|
||||
|
||||
export function safeParseManagedRecordSettingsPatch<
|
||||
Family extends ManagedRecordSettingsFamily,
|
||||
>(family: Family, value: unknown) {
|
||||
return MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS[family].safeParse(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same direct module inventory and default posture as `/api/auth/me/modules`.
|
||||
* Every entry is present for a fresh record so a Guardian can administer the
|
||||
* whole supported surface instead of seeing only previously persisted keys.
|
||||
*/
|
||||
export const MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS = Object.freeze(
|
||||
Object.fromEntries(
|
||||
WRITABLE_MODULE_KEYS.map((key) => [key, !MODULE_REGISTRY[key].optIn]),
|
||||
),
|
||||
) as Readonly<Record<(typeof WRITABLE_MODULE_KEYS)[number], boolean>>;
|
||||
|
||||
/** Preserve only directly-owned module keys from a legacy persisted blob. */
|
||||
export function managedModulePreferencesFrom(
|
||||
raw: unknown,
|
||||
): ManagedRecordSettingsPatch["modules"]["modulePreferences"] {
|
||||
const allowed: Record<string, boolean> = {
|
||||
...MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
};
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return allowed;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const parsed = modulePrefsPatchSchema.safeParse({ [key]: value });
|
||||
if (parsed.success && value !== undefined) {
|
||||
allowed[key] = value as boolean;
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface RecordSettingsDto {
|
||||
record: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
locale: string | null;
|
||||
timezone: string;
|
||||
kind: "managed";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The record-settings payload intentionally names the record it describes.
|
||||
* It is never an extension of `/api/auth/me`, which remains the actor's
|
||||
* identity payload while the browser is switched into another record.
|
||||
*/
|
||||
export function toRecordSettingsDto(source: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
locale: string | null;
|
||||
timezone: string;
|
||||
recordKind: "managed";
|
||||
}): RecordSettingsDto {
|
||||
return {
|
||||
record: {
|
||||
id: source.id,
|
||||
displayName: source.name ?? "",
|
||||
locale: source.locale,
|
||||
timezone: source.timezone,
|
||||
kind: source.recordKind,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
assertRecordSettingsAccess,
|
||||
resolveGuardianRecordSettingsAccess,
|
||||
type RecordSettingsAccess,
|
||||
} from "./access";
|
||||
export { toRecordSettingsDto, type RecordSettingsDto } from "./dto";
|
||||
export { resolveManagedIntegrationState } from "./integrations";
|
||||
export {
|
||||
classifySettingsDestination,
|
||||
isGuardianSettingsWriteAllowed,
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
type SettingsDestinationClassification,
|
||||
type SettingsDestinationKind,
|
||||
} from "./classification";
|
||||
export {
|
||||
isManagedRecordSettingsFamily,
|
||||
managedModulePreferencesFrom,
|
||||
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
parseManagedRecordSettingsPatch,
|
||||
safeParseManagedRecordSettingsPatch,
|
||||
type ManagedRecordSettingsFamily,
|
||||
type ManagedRecordSettingsPatch,
|
||||
} from "./configuration";
|
||||
export { assertRecordSettingsResponseForRecord } from "./response";
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
IntegrationKey,
|
||||
IntegrationState,
|
||||
} from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* A status-ledger row alone cannot prove a connection: integrations without a
|
||||
* row synthesize a healthy ledger state for the owner Settings dashboard. The
|
||||
* managed projection must instead report those as disconnected until its
|
||||
* record carries a real credential or connection row.
|
||||
*/
|
||||
export function resolveManagedIntegrationState(
|
||||
state: IntegrationState,
|
||||
connected: Record<IntegrationKey, boolean>,
|
||||
integration: IntegrationKey,
|
||||
): IntegrationState {
|
||||
return connected[integration] ? state : "disconnected";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* A request carries its active record through the session cookie, so the
|
||||
* client must still reject a response that arrives after a tab switched to a
|
||||
* different record. TanStack keys prevent cache aliasing; this closes the
|
||||
* in-flight response race before it can populate that key.
|
||||
*/
|
||||
export function assertRecordSettingsResponseForRecord(
|
||||
response: { recordId: string },
|
||||
expectedRecordId: string | null,
|
||||
): void {
|
||||
if (expectedRecordId === null || response.recordId !== expectedRecordId) {
|
||||
throw new Error("Managed record settings response did not match its key");
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
* opt into it gets a clean 422 rather than persisting an inert `true` the
|
||||
* gate would ignore anyway.
|
||||
*/
|
||||
const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
|
||||
export const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
|
||||
(k) => moduleDelegatesTo(k) === undefined && !isCodeDisabledModule(k),
|
||||
);
|
||||
|
||||
|
||||
@@ -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) }"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user