mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
refactor(settings): retire Research Mode — the switch governed nothing
Research Mode was the opt-in behind the estimated GLP-1 drug-level curve: turn it on, read a disclaimer, get the chart. The chart stopped consulting the flag several releases ago and has painted for every account since, so the switch on Settings → Advanced changed no screen in either position. An inert control that promises to hide a chart is worse than no control. The curve is simply part of the medication page now. Gone with the switch: the acknowledgment dialog, `/api/auth/me/research-mode`, the shared status DTO and its gate helper, the disclaimer-version stamp, the query key, and the three user columns the acknowledgment wrote (migration 0321). The Privacy page's second button pointed at the retired control and goes too. Settings → Advanced is now what it reads as: reset the data, delete the account.
This commit is contained in:
1 parent
beb070c0a0
commit
0160052289
28 files changed
+57
-1871
No files matched your search
+4
-38
@@ -1604,26 +1604,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Forschungsmodus aktivieren",
|
||||
"intro": "Der Forschungsmodus ergänzt deine GLP-1-Medikamentenseite um eine geschätzte Wirkstoff-Verlaufskurve. Lies die Abgrenzung unten, bevor du den Modus einschaltest — die Kurve ist eine Lernhilfe, niemals eine Messung und nie ein medizinischer Rat.",
|
||||
"whatItIsHeader": "Was das ist",
|
||||
"whatItIs": "Eine Sägezahnkurve, die die Überlagerung deiner protokollierten Dosen über die Zeit simuliert. Grundlage ist das Ein-Kompartment-Modell nach Bateman mit Absorptions- und Halbwertszeit-Parametern aus den EMA-EPARs der zugelassenen GLP-1-Rezeptoragonisten. Die Y-Achse ist einheitenlos und ohne Beschriftung. Die Form, nicht der Zahlenwert, ist die Aussage der Kurve.",
|
||||
"whatItIsntHeader": "Was das nicht ist",
|
||||
"whatItIsnt": "Kein Blutspiegel. Keine Messung. Keine Vorhersage, wie dein Körper tatsächlich reagiert. Keine Grundlage für Dosierungs-, Zeitpunkt- oder Eskalationsentscheidungen. Für alles, was deine Therapie betrifft, sprich mit deiner verschreibenden Ärztin oder deinem Arzt.",
|
||||
"whyEstimateHeader": "Warum es eine Schätzung ist",
|
||||
"whyEstimate": "Populations-Pharmakokinetik beschreibt den typischen Patienten. Die individuelle Streuung ist groß — das Tirzepatid-Modell Schneck/Urva 2024 weist rund 22 % interindividuelle Variabilität bei der Absorption und rund 49 % beim Verteilungsvolumen aus. Eine Lehrbuchkurve kann weder dein Gewicht, deine Nierenfunktion, deine Injektionstechnik noch deine Zeitgenauigkeit abbilden. Lies die Kurve als Rhythmus deiner Dosierung, nicht als deinen Spiegel.",
|
||||
"mdrBoundaryHeader": "Regulatorische Abgrenzung",
|
||||
"mdrBoundary": "HealthLog stellt öffentliche regulatorische Daten und deine eigenen Einträge dar. Die App stellt keine Prognose, Diagnose, Behandlung oder Therapieempfehlung. Die EU-Verordnung 2017/745 (Medical Device Regulation, MDR) und das Leitfadenpapier MDCG 2021-24 ziehen die Grenze bei Software, die Daten für medizinische Zwecke interpretiert — diese Kurve bleibt klar auf der edukativen Seite dieser Linie. Wenn du das Gefühl hast, die Kurve dränge dich zu einer klinischen Entscheidung, schließe sie.",
|
||||
"citationsHeader": "Quellen",
|
||||
"citations": "EMA European Public Assessment Reports für Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta und Bydureon (Abschnitt 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Disclaimer-Version: {version}",
|
||||
"acknowledgeCta": "Verstanden — aktivieren",
|
||||
"cancelCta": "Abbrechen",
|
||||
"successToast": "Forschungsmodus aktiviert. Die Wirkstoff-Verlaufskurve ist jetzt auf deiner GLP-1-Medikamentenseite sichtbar.",
|
||||
"errorToast": "Forschungsmodus konnte nicht aktiviert werden. Bitte erneut versuchen.",
|
||||
"staleVersionToast": "Der Disclaimer wurde aktualisiert, seit diese Seite geladen wurde. Lade die Seite neu, um die aktuelle Fassung zu lesen."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Geschätzter Wirkstoffverlauf",
|
||||
"axisLabel": "Geschätzter Spiegel (relativ)",
|
||||
@@ -4591,19 +4571,6 @@
|
||||
"remainingMany": "Noch {count} Messungen bis zum Trend."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Forschungsmodus",
|
||||
"subtitle": "Optionale Kurve des geschätzten GLP-1-Wirkstoffspiegels nach EMA-Daten.",
|
||||
"toggleLabel": "Geschätzte Wirkstoffkurve anzeigen",
|
||||
"disabledStatus": "Deaktiviert. Die Wirkstoffkurve ist ausgeblendet.",
|
||||
"enabledStaleStatus": "Der Disclaimer wurde aktualisiert. Bestätige unten erneut, um die Kurve wieder anzuzeigen.",
|
||||
"acknowledgedOn": "Bestätigt am {date}.",
|
||||
"rePromptTitle": "Disclaimer aktualisiert",
|
||||
"rePromptBody": "Der Disclaimer des Forschungsmodus wurde seit deiner letzten Bestätigung aktualisiert. Lies ihn erneut und bestätige, damit die Kurve sichtbar bleibt.",
|
||||
"rePromptCta": "Disclaimer erneut bestätigen",
|
||||
"disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen.",
|
||||
"subtitleDetail": "Nur zur Anschauung — niemals eine Messung oder ein medizinischer Rat."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Einstellungs-Bereiche",
|
||||
"groups": {
|
||||
@@ -4887,8 +4854,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweitert",
|
||||
"description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten.",
|
||||
"subtitle": "Research-Modus, Daten zurücksetzen und Konto löschen."
|
||||
"description": "Der Notausgang: alle Gesundheitsdaten unwiderruflich löschen oder das Konto auflösen.",
|
||||
"subtitle": "Daten zurücksetzen und Konto löschen."
|
||||
},
|
||||
"export": {
|
||||
"title": "Export & Import",
|
||||
@@ -6277,9 +6244,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Datenschutz-Einstellung",
|
||||
"description": "Steuere, wie viele deiner Daten an KI-Funktionen und die freiwillige Forschung weitergegeben werden.",
|
||||
"openAi": "KI-Datenschutzmodus",
|
||||
"openResearch": "Forschungsmodus"
|
||||
"description": "Steuere, wie viele deiner Daten an KI-Funktionen weitergegeben werden.",
|
||||
"openAi": "KI-Datenschutzmodus"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
|
||||
+4
-38
@@ -1604,26 +1604,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Enable Research Mode",
|
||||
"intro": "Research Mode adds an estimated drug-level chart to your GLP-1 medication page. Read the boundary below before turning it on — the chart is a learning aid, never a measurement, never medical advice.",
|
||||
"whatItIsHeader": "What this is",
|
||||
"whatItIs": "A sawtooth curve simulating how your logged doses superimpose over time, using the one-compartment Bateman model with absorption and half-life parameters published in the EMA EPAR for each approved GLP-1 receptor agonist. The vertical axis is unit-less and unlabeled. The shape, not the number, is what the chart shows.",
|
||||
"whatItIsntHeader": "What this isn't",
|
||||
"whatItIsnt": "Not a blood level. Not a measurement. Not a forecast of how your body actually responds. Not a basis for any dose, timing, or escalation decision. Talk to your prescribing clinician for anything that affects how you take your medication.",
|
||||
"whyEstimateHeader": "Why it's an estimate",
|
||||
"whyEstimate": "Population pharmacokinetics describe the typical patient. Individual variation is large — tirzepatide's Schneck/Urva 2024 model reports ~22% inter-individual variability on absorption and ~49% on volume of distribution. A textbook curve cannot capture your weight, kidney function, injection technique, or timing accuracy. Read the curve as the rhythm of dosing, not as your level.",
|
||||
"mdrBoundaryHeader": "Regulatory boundary",
|
||||
"mdrBoundary": "HealthLog displays public regulatory data and your own log entries. It does not predict, diagnose, treat, or recommend any therapy. EU Regulation 2017/745 (the Medical Device Regulation, MDR) and guidance document MDCG 2021-24 draw the line at software that interprets data for medical purposes — this chart stays decisively on the educational side of that line. If you ever feel it's nudging you toward a clinical decision, close it.",
|
||||
"citationsHeader": "Sources",
|
||||
"citations": "EMA European Public Assessment Reports for Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta, and Bydureon (Section 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Disclaimer version: {version}",
|
||||
"acknowledgeCta": "I understand — enable",
|
||||
"cancelCta": "Cancel",
|
||||
"successToast": "Research Mode enabled. The drug-level chart is now visible on your GLP-1 medication page.",
|
||||
"errorToast": "Could not enable Research Mode. Please try again.",
|
||||
"staleVersionToast": "The disclaimer was updated since this page loaded. Reload to read the current version."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Estimated drug level",
|
||||
"axisLabel": "Estimated level (relative)",
|
||||
@@ -4591,19 +4571,6 @@
|
||||
"remainingMany": "{count} more readings to go."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Research Mode",
|
||||
"subtitle": "Opt-in curve of the estimated GLP-1 drug level, from EMA-published data.",
|
||||
"toggleLabel": "Show the estimated drug-level chart",
|
||||
"disabledStatus": "Disabled. The drug-level chart is hidden.",
|
||||
"enabledStaleStatus": "The disclaimer was updated. Re-acknowledge below to bring the chart back.",
|
||||
"acknowledgedOn": "Acknowledged on {date}.",
|
||||
"rePromptTitle": "Disclaimer updated",
|
||||
"rePromptBody": "The Research Mode disclaimer was updated since you last acknowledged it. Re-read and re-confirm to keep the chart visible.",
|
||||
"rePromptCta": "Re-acknowledge disclaimer",
|
||||
"disableError": "Could not disable Research Mode. Please try again.",
|
||||
"subtitleDetail": "Reading-only — never a measurement or medical advice."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections",
|
||||
"groups": {
|
||||
@@ -4887,8 +4854,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record.",
|
||||
"subtitle": "Research mode, data reset, and account deletion."
|
||||
"description": "The way out: erase every health record for good, or close the account entirely.",
|
||||
"subtitle": "Data reset and account deletion."
|
||||
},
|
||||
"export": {
|
||||
"title": "Export & Import",
|
||||
@@ -6277,9 +6244,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Privacy posture",
|
||||
"description": "Control how much your data is shared with AI features and opt-in research.",
|
||||
"openAi": "AI privacy mode",
|
||||
"openResearch": "Research mode"
|
||||
"description": "Control how much of your data is shared with AI features.",
|
||||
"openAi": "AI privacy mode"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
|
||||
+4
-38
@@ -1604,26 +1604,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Activar el modo Investigación",
|
||||
"intro": "El modo Investigación añade un gráfico estimado del nivel de fármaco a tu página de GLP-1. Lee el límite a continuación antes de activarlo: el gráfico es una ayuda de aprendizaje, nunca una medición, nunca un consejo médico.",
|
||||
"whatItIsHeader": "Qué es esto",
|
||||
"whatItIs": "Una curva en diente de sierra que simula la superposición de tus dosis registradas a lo largo del tiempo, usando el modelo de Bateman de un compartimento con los parámetros de absorción y semivida publicados en el EPAR de la EMA de cada agonista GLP-1 aprobado. El eje vertical es adimensional y sin etiquetas. La forma, no el número, es lo que muestra el gráfico.",
|
||||
"whatItIsntHeader": "Qué no es",
|
||||
"whatItIsnt": "No es un nivel en sangre. No es una medición. No es una predicción de cómo responde realmente tu cuerpo. No es base para ninguna decisión de dosis, tiempo o escalado. Para cualquier cosa que afecte cómo tomas tu medicación, habla con tu médico prescriptor.",
|
||||
"whyEstimateHeader": "Por qué es una estimación",
|
||||
"whyEstimate": "La farmacocinética poblacional describe al paciente típico. La variación individual es grande: el modelo Schneck/Urva 2024 de tirzepatida reporta aproximadamente un 22 % de variabilidad interindividual en la absorción y aproximadamente un 49 % en el volumen de distribución. Una curva de manual no puede capturar tu peso, tu función renal, tu técnica de inyección ni la precisión de tus horarios. Lee la curva como el ritmo de tu dosificación, no como tu nivel.",
|
||||
"mdrBoundaryHeader": "Límite regulatorio",
|
||||
"mdrBoundary": "HealthLog muestra datos regulatorios públicos y tus propios registros. La aplicación no predice, no diagnostica, no trata y no recomienda ninguna terapia. El Reglamento UE 2017/745 (Medical Device Regulation, MDR) y el documento de orientación MDCG 2021-24 trazan la línea con el software que interpreta datos con fines médicos: este gráfico se queda decididamente del lado educativo de esa línea. Si en algún momento sientes que te empuja hacia una decisión clínica, ciérralo.",
|
||||
"citationsHeader": "Fuentes",
|
||||
"citations": "European Public Assessment Reports de la EMA para Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta y Bydureon (Sección 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Versión del disclaimer: {version}",
|
||||
"acknowledgeCta": "Entiendo — activar",
|
||||
"cancelCta": "Cancelar",
|
||||
"successToast": "Modo Investigación activado. La curva de nivel de fármaco es ahora visible en tu página de GLP-1.",
|
||||
"errorToast": "No se pudo activar el modo Investigación. Inténtalo de nuevo.",
|
||||
"staleVersionToast": "El disclaimer se actualizó desde que cargó esta página. Recarga para leer la versión actual."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Nivel de fármaco estimado",
|
||||
"axisLabel": "Nivel estimado (relativo)",
|
||||
@@ -4591,19 +4571,6 @@
|
||||
"remainingMany": "Faltan {count} mediciones para la tendencia."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Modo Investigación",
|
||||
"subtitle": "Curva opcional del nivel estimado del fármaco GLP-1, según datos de la EMA.",
|
||||
"toggleLabel": "Mostrar la curva estimada del nivel de fármaco",
|
||||
"disabledStatus": "Desactivado. La curva está oculta.",
|
||||
"enabledStaleStatus": "El disclaimer se actualizó. Reconfirma debajo para volver a mostrar la curva.",
|
||||
"acknowledgedOn": "Confirmado el {date}.",
|
||||
"rePromptTitle": "Disclaimer actualizado",
|
||||
"rePromptBody": "El disclaimer del modo Investigación se actualizó desde tu última confirmación. Relelo y reconfirma para mantener la curva visible.",
|
||||
"rePromptCta": "Reconfirmar disclaimer",
|
||||
"disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo.",
|
||||
"subtitleDetail": "Solo lectura: nunca una medición ni un consejo médico."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Secciones de ajustes",
|
||||
"groups": {
|
||||
@@ -4887,8 +4854,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avanzado",
|
||||
"description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud.",
|
||||
"subtitle": "Modo investigación, restablecer datos y eliminar cuenta."
|
||||
"description": "La salida: borra todos los registros de salud para siempre o cierra la cuenta.",
|
||||
"subtitle": "Restablecer datos y eliminar cuenta."
|
||||
},
|
||||
"export": {
|
||||
"title": "Exportar e importar",
|
||||
@@ -6277,9 +6244,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Configuración de privacidad",
|
||||
"description": "Controla cuántos de tus datos se comparten con las funciones de IA y la investigación opcional.",
|
||||
"openAi": "Modo de privacidad de IA",
|
||||
"openResearch": "Modo investigación"
|
||||
"description": "Controla cuántos de tus datos se comparten con las funciones de IA.",
|
||||
"openAi": "Modo de privacidad de IA"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
|
||||
+4
-38
@@ -1604,26 +1604,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Activer le mode Recherche",
|
||||
"intro": "Le mode Recherche ajoute un graphique estimé du niveau de médicament à votre page GLP-1. Lisez la limite ci-dessous avant de l'activer — le graphique est un outil d'apprentissage, jamais une mesure, jamais un conseil médical.",
|
||||
"whatItIsHeader": "Ce que c'est",
|
||||
"whatItIs": "Une courbe en dents de scie qui simule la superposition de vos doses enregistrées dans le temps, à partir du modèle Bateman à un compartiment avec les paramètres d'absorption et de demi-vie publiés dans l'EPAR EMA de chaque agoniste GLP-1 approuvé. L'axe vertical est sans unité et non étiqueté. C'est la forme, pas la valeur, qui est l'information de la courbe.",
|
||||
"whatItIsntHeader": "Ce que ce n'est pas",
|
||||
"whatItIsnt": "Pas un taux sanguin. Pas une mesure. Pas une prévision de la réponse réelle de votre corps. Pas une base pour une décision de dose, de timing ou d'escalade. Pour tout ce qui touche à votre traitement, parlez-en à votre médecin prescripteur.",
|
||||
"whyEstimateHeader": "Pourquoi c'est une estimation",
|
||||
"whyEstimate": "La pharmacocinétique de population décrit le patient typique. La variabilité individuelle est grande — le modèle Schneck/Urva 2024 du tirzépatide rapporte environ 22 % de variabilité interindividuelle sur l'absorption et environ 49 % sur le volume de distribution. Une courbe de manuel ne peut capturer ni votre poids, ni votre fonction rénale, ni votre technique d'injection, ni la précision de vos horaires. Lisez la courbe comme le rythme de votre dosage, pas comme votre niveau.",
|
||||
"mdrBoundaryHeader": "Limite réglementaire",
|
||||
"mdrBoundary": "HealthLog affiche des données réglementaires publiques et vos propres enregistrements. L'application ne prédit, ne diagnostique, ne traite et ne recommande aucune thérapie. Le règlement UE 2017/745 (Medical Device Regulation, MDR) et le document de guidance MDCG 2021-24 tracent la limite à partir des logiciels qui interprètent des données à des fins médicales — cette courbe reste résolument du côté éducatif de cette limite. Si vous avez le sentiment qu'elle vous pousse vers une décision clinique, fermez-la.",
|
||||
"citationsHeader": "Sources",
|
||||
"citations": "Rapports européens publics d'évaluation (EPAR) de l'EMA pour Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta et Bydureon (section 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Version du disclaimer : {version}",
|
||||
"acknowledgeCta": "J'ai compris — activer",
|
||||
"cancelCta": "Annuler",
|
||||
"successToast": "Mode Recherche activé. La courbe de niveau de médicament est maintenant visible sur votre page GLP-1.",
|
||||
"errorToast": "Impossible d'activer le mode Recherche. Veuillez réessayer.",
|
||||
"staleVersionToast": "Le disclaimer a été mis à jour depuis le chargement de cette page. Rechargez pour lire la version actuelle."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Niveau de médicament estimé",
|
||||
"axisLabel": "Niveau estimé (relatif)",
|
||||
@@ -4591,19 +4571,6 @@
|
||||
"remainingMany": "Encore {count} mesures avant la tendance."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Mode Recherche",
|
||||
"subtitle": "Courbe optionnelle du niveau estimé de GLP-1, d'après les données de l'EMA.",
|
||||
"toggleLabel": "Afficher la courbe estimée du niveau de médicament",
|
||||
"disabledStatus": "Désactivé. La courbe est masquée.",
|
||||
"enabledStaleStatus": "Le disclaimer a été mis à jour. Reconfirmez ci-dessous pour réafficher la courbe.",
|
||||
"acknowledgedOn": "Confirmé le {date}.",
|
||||
"rePromptTitle": "Disclaimer mis à jour",
|
||||
"rePromptBody": "Le disclaimer du mode Recherche a été mis à jour depuis votre dernière confirmation. Relisez-le et reconfirmez pour garder la courbe visible.",
|
||||
"rePromptCta": "Reconfirmer le disclaimer",
|
||||
"disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer.",
|
||||
"subtitleDetail": "Lecture seule — jamais une mesure ni un conseil médical."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sections des réglages",
|
||||
"groups": {
|
||||
@@ -4887,8 +4854,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avancé",
|
||||
"description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé.",
|
||||
"subtitle": "Mode recherche, réinitialisation des données et suppression du compte."
|
||||
"description": "La sortie : effacer définitivement toutes les données de santé ou fermer le compte.",
|
||||
"subtitle": "Réinitialisation des données et suppression du compte."
|
||||
},
|
||||
"export": {
|
||||
"title": "Export et import",
|
||||
@@ -6277,9 +6244,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Posture de confidentialité",
|
||||
"description": "Contrôlez la quantité de données partagées avec les fonctions d'IA et la recherche optionnelle.",
|
||||
"openAi": "Mode confidentialité IA",
|
||||
"openResearch": "Mode recherche"
|
||||
"description": "Contrôlez la quantité de données partagées avec les fonctions d'IA.",
|
||||
"openAi": "Mode confidentialité IA"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
|
||||
+4
-38
@@ -1604,26 +1604,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Attiva la modalità Ricerca",
|
||||
"intro": "La modalità Ricerca aggiunge un grafico stimato del livello del farmaco alla tua pagina GLP-1. Leggi il limite qui sotto prima di attivarla: il grafico è uno strumento di apprendimento, mai una misura, mai un consiglio medico.",
|
||||
"whatItIsHeader": "Cosa è",
|
||||
"whatItIs": "Una curva a dente di sega che simula la sovrapposizione delle dosi registrate nel tempo, usando il modello Bateman a un compartimento con i parametri di assorbimento e di emivita pubblicati nell'EPAR EMA di ogni agonista GLP-1 approvato. L'asse verticale è privo di unità ed etichette. La forma, non il numero, è ciò che il grafico mostra.",
|
||||
"whatItIsntHeader": "Cosa non è",
|
||||
"whatItIsnt": "Non è un livello ematico. Non è una misura. Non è una previsione della risposta reale del tuo corpo. Non è una base per alcuna decisione di dose, di tempistica o di escalation. Per qualunque cosa riguardi come assumi il tuo farmaco, parla con il medico che te lo prescrive.",
|
||||
"whyEstimateHeader": "Perché è una stima",
|
||||
"whyEstimate": "La farmacocinetica di popolazione descrive il paziente tipico. La variabilità individuale è ampia: il modello Schneck/Urva 2024 del tirzepatide riporta circa il 22 % di variabilità interindividuale sull'assorbimento e circa il 49 % sul volume di distribuzione. Una curva da manuale non può catturare il tuo peso, la tua funzione renale, la tua tecnica di iniezione o la precisione degli orari. Leggi la curva come il ritmo del tuo dosaggio, non come il tuo livello.",
|
||||
"mdrBoundaryHeader": "Limite regolatorio",
|
||||
"mdrBoundary": "HealthLog mostra dati regolatori pubblici e le tue registrazioni. L'app non predice, non diagnostica, non tratta e non raccomanda alcuna terapia. Il Regolamento UE 2017/745 (Medical Device Regulation, MDR) e il documento di orientamento MDCG 2021-24 tracciano la linea sul software che interpreta dati per scopi medici: questa curva rimane decisamente sul lato educativo di quella linea. Se in qualunque momento ti sembra spingere verso una decisione clinica, chiudila.",
|
||||
"citationsHeader": "Fonti",
|
||||
"citations": "European Public Assessment Reports EMA per Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta e Bydureon (Sezione 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Versione del disclaimer: {version}",
|
||||
"acknowledgeCta": "Ho capito — attiva",
|
||||
"cancelCta": "Annulla",
|
||||
"successToast": "Modalità Ricerca attivata. La curva del livello del farmaco è ora visibile sulla tua pagina GLP-1.",
|
||||
"errorToast": "Impossibile attivare la modalità Ricerca. Riprova.",
|
||||
"staleVersionToast": "Il disclaimer è stato aggiornato dal caricamento di questa pagina. Ricarica per leggere la versione attuale."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Livello stimato del farmaco",
|
||||
"axisLabel": "Livello stimato (relativo)",
|
||||
@@ -4591,19 +4571,6 @@
|
||||
"remainingMany": "Mancano {count} misurazioni al trend."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Modalità Ricerca",
|
||||
"subtitle": "Curva opzionale del livello stimato di GLP-1, da dati pubblicati dall'EMA.",
|
||||
"toggleLabel": "Mostra la curva stimata del livello del farmaco",
|
||||
"disabledStatus": "Disattivata. La curva è nascosta.",
|
||||
"enabledStaleStatus": "Il disclaimer è stato aggiornato. Riconferma sotto per rivedere la curva.",
|
||||
"acknowledgedOn": "Confermato il {date}.",
|
||||
"rePromptTitle": "Disclaimer aggiornato",
|
||||
"rePromptBody": "Il disclaimer della modalità Ricerca è stato aggiornato dall'ultima tua conferma. Rileggilo e riconferma per mantenere visibile la curva.",
|
||||
"rePromptCta": "Riconferma disclaimer",
|
||||
"disableError": "Impossibile disattivare la modalità Ricerca. Riprova.",
|
||||
"subtitleDetail": "Solo lettura: mai una misurazione né un consiglio medico."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sezioni delle impostazioni",
|
||||
"groups": {
|
||||
@@ -4887,8 +4854,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avanzate",
|
||||
"description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute.",
|
||||
"subtitle": "Modalità ricerca, ripristino dati ed eliminazione account."
|
||||
"description": "La via d'uscita: cancella per sempre tutti i dati di salute o chiudi l'account.",
|
||||
"subtitle": "Ripristino dati ed eliminazione account."
|
||||
},
|
||||
"export": {
|
||||
"title": "Esporta e importa",
|
||||
@@ -6277,9 +6244,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Impostazione privacy",
|
||||
"description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA e la ricerca facoltativa.",
|
||||
"openAi": "Modalità privacy IA",
|
||||
"openResearch": "Modalità ricerca"
|
||||
"description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA.",
|
||||
"openAi": "Modalità privacy IA"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
|
||||
+4
-38
@@ -1604,26 +1604,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Włącz tryb Badawczy",
|
||||
"intro": "Tryb Badawczy dodaje szacowany wykres poziomu leku do twojej strony GLP-1. Przed jego włączeniem przeczytaj granicę poniżej — wykres jest pomocą edukacyjną, nigdy pomiarem i nigdy poradą medyczną.",
|
||||
"whatItIsHeader": "Czym to jest",
|
||||
"whatItIs": "Piłokształtną krzywą, która symuluje nakładanie się twoich zarejestrowanych dawek w czasie, na podstawie jednokompartmentowego modelu Batemana z parametrami absorpcji i okresu półtrwania opublikowanymi w EPAR EMA dla każdego zatwierdzonego agonisty GLP-1. Oś pionowa jest bezjednostkowa i nieopisana. To kształt, a nie liczba, jest tym, co pokazuje wykres.",
|
||||
"whatItIsntHeader": "Czym to nie jest",
|
||||
"whatItIsnt": "Nie jest poziomem we krwi. Nie jest pomiarem. Nie jest prognozą faktycznej reakcji twojego organizmu. Nie jest podstawą żadnej decyzji o dawce, czasie ani eskalacji. We wszystkim, co dotyczy sposobu przyjmowania leku, porozmawiaj z lekarzem prowadzącym.",
|
||||
"whyEstimateHeader": "Dlaczego to oszacowanie",
|
||||
"whyEstimate": "Farmakokinetyka populacyjna opisuje typowego pacjenta. Zmienność osobnicza jest duża — model Schneck/Urva 2024 dla tirzepatydu raportuje około 22 % międzyosobniczej zmienności absorpcji i około 49 % objętości dystrybucji. Krzywa z podręcznika nie uchwyci ani twojej masy ciała, ani funkcji nerek, ani techniki iniekcji, ani precyzji czasu. Czytaj krzywą jako rytm dawkowania, a nie twój poziom.",
|
||||
"mdrBoundaryHeader": "Granica regulacyjna",
|
||||
"mdrBoundary": "HealthLog wyświetla publiczne dane regulacyjne i twoje własne wpisy. Aplikacja nie prognozuje, nie diagnozuje, nie leczy i nie rekomenduje żadnej terapii. Rozporządzenie UE 2017/745 (Medical Device Regulation, MDR) oraz dokument MDCG 2021-24 wyznaczają granicę przy oprogramowaniu interpretującym dane do celów medycznych — ten wykres pozostaje zdecydowanie po edukacyjnej stronie tej granicy. Jeśli kiedykolwiek poczujesz, że kieruje cię w stronę decyzji klinicznej, zamknij go.",
|
||||
"citationsHeader": "Źródła",
|
||||
"citations": "European Public Assessment Reports EMA dla Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta i Bydureon (Sekcja 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Wersja disclaimera: {version}",
|
||||
"acknowledgeCta": "Rozumiem — włącz",
|
||||
"cancelCta": "Anuluj",
|
||||
"successToast": "Tryb Badawczy włączony. Krzywa poziomu leku jest teraz widoczna na twojej stronie GLP-1.",
|
||||
"errorToast": "Nie udało się włączyć trybu Badawczego. Spróbuj ponownie.",
|
||||
"staleVersionToast": "Disclaimer został zaktualizowany od czasu wczytania tej strony. Odśwież, aby przeczytać aktualną wersję."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Szacowany poziom leku",
|
||||
"axisLabel": "Szacowany poziom (względny)",
|
||||
@@ -4591,19 +4571,6 @@
|
||||
"remainingMany": "Jeszcze {count} pomiarów do trendu."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Tryb Badawczy",
|
||||
"subtitle": "Opcjonalna krzywa szacowanego poziomu leku GLP-1 wg danych EMA.",
|
||||
"toggleLabel": "Pokaż szacowaną krzywą poziomu leku",
|
||||
"disabledStatus": "Wyłączone. Krzywa jest ukryta.",
|
||||
"enabledStaleStatus": "Disclaimer został zaktualizowany. Potwierdź ponownie poniżej, aby przywrócić krzywą.",
|
||||
"acknowledgedOn": "Potwierdzono {date}.",
|
||||
"rePromptTitle": "Zaktualizowano disclaimer",
|
||||
"rePromptBody": "Disclaimer trybu Badawczego został zaktualizowany od czasu twojego ostatniego potwierdzenia. Przeczytaj go ponownie i potwierdź, aby krzywa pozostała widoczna.",
|
||||
"rePromptCta": "Potwierdź ponownie disclaimer",
|
||||
"disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie.",
|
||||
"subtitleDetail": "Tylko do odczytu — nigdy pomiar ani porada medyczna."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sekcje ustawień",
|
||||
"groups": {
|
||||
@@ -4887,8 +4854,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Zaawansowane",
|
||||
"description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych.",
|
||||
"subtitle": "Tryb badawczy, reset danych i usunięcie konta."
|
||||
"description": "Wyjście awaryjne: trwale usuń wszystkie dane zdrowotne albo zamknij konto.",
|
||||
"subtitle": "Reset danych i usunięcie konta."
|
||||
},
|
||||
"export": {
|
||||
"title": "Eksport i import",
|
||||
@@ -6277,9 +6244,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Ustawienia prywatności",
|
||||
"description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI i opcjonalnym badaniom.",
|
||||
"openAi": "Tryb prywatności AI",
|
||||
"openResearch": "Tryb badawczy"
|
||||
"description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI.",
|
||||
"openAi": "Tryb prywatności AI"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Research Mode is gone: the estimated GLP-1 drug-level curve is part of the
|
||||
-- medication page for everyone, so the opt-in flag and the two acknowledgment
|
||||
-- stamps it kept have no reader and no writer left. The endpoint, the
|
||||
-- acknowledgment dialog and the Settings switch went with them.
|
||||
ALTER TABLE "users"
|
||||
DROP COLUMN IF EXISTS "research_mode_enabled",
|
||||
DROP COLUMN IF EXISTS "research_mode_acknowledged_at",
|
||||
DROP COLUMN IF EXISTS "research_mode_acknowledged_version";
|
||||
+2
-25
@@ -427,9 +427,8 @@ model User {
|
||||
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Shown
|
||||
// once at the start of onboarding: HealthLog is a private tracking tool,
|
||||
// not a clinical assessment or diagnosis; if worried, talk to a doctor.
|
||||
// Mirrors the `researchModeAcknowledged*` pair — the version pins exactly
|
||||
// which copy the user read, so a future material wording change can ask
|
||||
// again. Null = never acknowledged (legacy accounts read as never-asked;
|
||||
// The version pins exactly which copy the user read, so a future material
|
||||
// wording change can ask again. Null = never acknowledged (legacy accounts read as never-asked;
|
||||
// the prompt only gates fresh onboarding, so they are not re-walled). The
|
||||
// acknowledged legal text stays reachable on the public privacy page.
|
||||
disclaimerAcknowledgedAt DateTime? @map("disclaimer_acknowledged_at")
|
||||
@@ -496,28 +495,6 @@ model User {
|
||||
// work.
|
||||
sourcePriorityJson Json? @map("source_priority_json")
|
||||
|
||||
// v1.4.25 W19c — Research Mode acknowledgment for the opt-in GLP-1
|
||||
// PK research-view chart. The chart is hidden behind a per-user
|
||||
// flag that is set to `true` only after the user has dismissed an
|
||||
// undismissable MDR-disclaimer dialog. The `researchModeAcknowledged*`
|
||||
// pair records exactly *which version* of the disclaimer copy the
|
||||
// user has read; when the disclaimer copy changes (e.g. EMA guidance
|
||||
// update, new drug added), the Settings surface re-prompts the user
|
||||
// and refuses to surface the chart until the new version is
|
||||
// acknowledged. Keeping the version on the user row (instead of an
|
||||
// append-only audit table) is intentional — the Coach refusal layer
|
||||
// and the chart-render guard both need a cheap synchronous read.
|
||||
//
|
||||
// `researchModeEnabled = false` is the safe default — the chart
|
||||
// never renders without an explicit opt-in. The DELETE branch of
|
||||
// the API endpoint sets all three columns back to NULL/false so a
|
||||
// user who turns Research Mode off must re-acknowledge the
|
||||
// disclaimer if they ever turn it back on, even if the version
|
||||
// hasn't changed in the interim.
|
||||
researchModeEnabled Boolean @default(false) @map("research_mode_enabled")
|
||||
researchModeAcknowledgedAt DateTime? @map("research_mode_acknowledged_at")
|
||||
researchModeAcknowledgedVersion String? @map("research_mode_acknowledged_version")
|
||||
|
||||
// v1.4.30 — iOS SyncMode foundation (R-E C-2). Last-known
|
||||
// synchronization checkpoint per user. iOS reads it via
|
||||
// `GET /api/sync/state` to decide whether to drain its local
|
||||
|
||||
@@ -111,13 +111,6 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
"every health record on the account, or the whole account including passkeys and audit history",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "research-mode switch, off position",
|
||||
reason:
|
||||
"Turning research mode off drops the stored acknowledgement receipt, which is regenerated by turning it back on and re-acknowledging. Nothing a person authored or measured is involved, so a dialog on a labelled switch would gate a preference rather than a loss.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/settings/security-sessions-card.tsx",
|
||||
|
||||
@@ -230,9 +230,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/medications/intake-history-list-v2.tsx": {
|
||||
"text-success": 2,
|
||||
},
|
||||
"src/components/medications/research-mode-acknowledgment-dialog.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/scheduling/schedule-history-timeline.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
@@ -286,7 +283,7 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/settings/account-section/avatar-section.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/account-section/index.tsx": { "text-success": 2 },
|
||||
"src/components/settings/account-section/index.tsx": { "text-success": 1 },
|
||||
"src/components/settings/advanced-section.tsx": { "text-success": 2 },
|
||||
"src/components/settings/ai/ai-insights-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/anthropic-provider-form.tsx": {
|
||||
@@ -341,6 +338,9 @@ const PINNED_AFFORDANCES: Record<
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/ntfy-card.tsx": { "text-success": 1 },
|
||||
// The one that used to sit inside `account-section/index.tsx` — the card
|
||||
// moved to Settings → Security, the line moved with it.
|
||||
"src/components/settings/password-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/security-sessions-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/share-link-create-form.tsx": { "text-success": 3 },
|
||||
"src/components/settings/sharing-section.tsx": { "text-success": 1 },
|
||||
|
||||
@@ -227,7 +227,7 @@ describe("PATCH /api/auth/me/disable-coach", () => {
|
||||
|
||||
it("is idempotent when the column already holds the requested value", async () => {
|
||||
// Toggling a column from true→true (or false→false) still writes
|
||||
// through so the audit row exists. Matches the research-mode
|
||||
// through so the audit row exists. Matches the doctor-report-prefs
|
||||
// route's "always write" posture.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE, GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPost(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/research-mode", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function mkDelete(): Request {
|
||||
return new Request("http://localhost/api/auth/me/research-mode", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the disabled default for a fresh user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(false);
|
||||
expect(env.data.acknowledgedAt).toBeNull();
|
||||
expect(env.data.acknowledgedVersion).toBeNull();
|
||||
expect(env.data.currentDisclaimerVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the enabled state with the persisted version", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const ackAt = new Date("2026-05-14T10:00:00Z");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: ackAt,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string;
|
||||
acknowledgedVersion: string;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables Research Mode and writes the audit log on a valid ack", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { enabled: boolean; acknowledgedVersion: string };
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.research-mode.enable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
next: expect.objectContaining({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a stale disclaimer version with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: "1970-01-01.0" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a body without acknowledged=true with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Research Mode and clears the acknowledgment", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: new Date(),
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(false);
|
||||
expect(env.data.acknowledgedAt).toBeNull();
|
||||
expect(env.data.acknowledgedVersion).toBeNull();
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.research-mode.disable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
next: {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent when Research Mode is already off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(200);
|
||||
// The route writes the canonical off-state even when the
|
||||
// column is already off — guarantees audit trail symmetry.
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19c — Research Mode acknowledgment endpoint.
|
||||
*
|
||||
* GET /api/auth/me/research-mode — returns the current flag,
|
||||
* the timestamp + version of
|
||||
* the last acknowledgment, and
|
||||
* the live disclaimer version
|
||||
* so the Settings UI can
|
||||
* compare and re-prompt.
|
||||
* POST /api/auth/me/research-mode — body { acknowledged: true,
|
||||
* version }. Flips the flag
|
||||
* on. Rejects 400 if `version`
|
||||
* is not the live version.
|
||||
* DELETE /api/auth/me/research-mode — disables Research Mode and
|
||||
* clears the acknowledgment.
|
||||
* Idempotent.
|
||||
*
|
||||
* Per-user POST rate-limit is intentionally low (5/min) — this is a
|
||||
* one-tap acknowledgment dialog, not a hot path. Anything above that
|
||||
* traffic looks like a malformed client retry-loop or an exploit.
|
||||
*
|
||||
* Prompt-injection surface — the API accepts the disclaimer
|
||||
* `version` string and nothing else from the client. The version
|
||||
* value never reaches the LLM; it is matched against the
|
||||
* server-side constant `RESEARCH_MODE_DISCLAIMER_VERSION` and then
|
||||
* persisted as the stamp on the user row. No free-text body.
|
||||
*
|
||||
* Audit-log entries fire on every state-changing call (POST, DELETE)
|
||||
* with the previous + next state, mirroring the doctor-report-prefs
|
||||
* route's reconcile pattern (W10 security M-3).
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const POST_RATE_LIMIT = 5;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
type ResearchModeResponse = {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
|
||||
function shape(row: {
|
||||
researchModeEnabled: boolean;
|
||||
researchModeAcknowledgedAt: Date | null;
|
||||
researchModeAcknowledgedVersion: string | null;
|
||||
}): ResearchModeResponse {
|
||||
return {
|
||||
enabled: row.researchModeEnabled,
|
||||
acknowledgedAt: row.researchModeAcknowledgedAt
|
||||
? row.researchModeAcknowledgedAt.toISOString()
|
||||
: null,
|
||||
acknowledgedVersion: row.researchModeAcknowledgedVersion,
|
||||
currentDisclaimerVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.research-mode.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
// `requireAuth` already proved the user exists; defensive default
|
||||
// here only matters for the (impossible-in-practice) race where
|
||||
// the user is deleted mid-request.
|
||||
return apiSuccess(
|
||||
shape(
|
||||
row ?? {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Per-user rate-limit. 5/min is generous for a once-per-update
|
||||
// acknowledgment dialog; anything more looks like a retry loop.
|
||||
const rl = await checkRateLimit(
|
||||
`research-mode:post:${user.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "research-mode.body.invalid_json");
|
||||
}
|
||||
|
||||
// Hand-rolled validation — the body is tiny (two fields), and
|
||||
// staying off Zod keeps the prompt-injection surface visibly
|
||||
// simple in a code review.
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== "object" ||
|
||||
(body as { acknowledged?: unknown }).acknowledged !== true ||
|
||||
typeof (body as { version?: unknown }).version !== "string"
|
||||
) {
|
||||
annotate({ action: { name: "auth.me.research-mode.post.invalid_shape" } });
|
||||
throw new HttpError(422, "research-mode.body.invalid_shape");
|
||||
}
|
||||
|
||||
const submittedVersion = (body as { version: string }).version;
|
||||
|
||||
// Stale-version guard — the dialog must show the *live* copy at
|
||||
// the moment of acknowledgment. A stale version means the user
|
||||
// is acknowledging older wording; we refuse and force the
|
||||
// Settings UI to re-render with the new disclaimer.
|
||||
if (submittedVersion !== RESEARCH_MODE_DISCLAIMER_VERSION) {
|
||||
annotate({
|
||||
action: { name: "auth.me.research-mode.post.stale_version" },
|
||||
meta: {
|
||||
submitted: submittedVersion,
|
||||
current: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
},
|
||||
});
|
||||
throw new HttpError(400, "research-mode.version.stale");
|
||||
}
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
const next = {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: new Date(),
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: next,
|
||||
});
|
||||
|
||||
await auditLog("user.research-mode.enable", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: previous ?? null,
|
||||
next: {
|
||||
...next,
|
||||
researchModeAcknowledgedAt:
|
||||
next.researchModeAcknowledgedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.research-mode.post" },
|
||||
meta: { version: RESEARCH_MODE_DISCLAIMER_VERSION },
|
||||
});
|
||||
return apiSuccess(shape(next));
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Idempotent — if Research Mode is already off, we still write
|
||||
// the canonical "off" state so the audit row exists.
|
||||
const next = {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: next,
|
||||
});
|
||||
|
||||
await auditLog("user.research-mode.disable", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { previous: previous ?? null, next },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.me.research-mode.delete" } });
|
||||
return apiSuccess(shape(next));
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// The Radix Dialog portals at runtime, so its body never materialises in
|
||||
// static markup. Collapse the primitives to plain wrappers (same trick as
|
||||
// the ResearchModeAcknowledgmentDialog suite) so the footer is reachable.
|
||||
// the dose-history dialog suite) so the footer is reachable.
|
||||
vi.mock("@/components/ui/dialog", () => {
|
||||
const Pass = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19c-Frontend — Research-mode acknowledgment dialog tests.
|
||||
*
|
||||
* The dialog is rendered controlled by Settings (commit 3) and gates
|
||||
* the GLP-1 drug-level chart (commit 2). The contract is:
|
||||
*
|
||||
* 1. Multi-section MDR copy renders (what-is, what-isn't, why,
|
||||
* MDR boundary, citations).
|
||||
* 2. Acknowledge CTA fires a `POST /api/auth/me/research-mode` with
|
||||
* the exact server-supplied version string.
|
||||
* 3. Cancel CTA fires `onOpenChange(false)` without an API call.
|
||||
* 4. The stale-version error (server 400 with
|
||||
* `research-mode.version.stale`) surfaces a localised error toast
|
||||
* and keeps the dialog open.
|
||||
*
|
||||
* Setup mirrors the existing `coach-settings-sheet.test.tsx` pattern:
|
||||
* SSR via `renderToStaticMarkup` so the Radix Dialog primitives are
|
||||
* mocked to plain wrappers (Portals don't materialise under SSR), and
|
||||
* the TanStack Query mutation is captured so we can drive the
|
||||
* onSuccess / onError branches by hand.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const mutationCallbacks: {
|
||||
mutationFn: ((version: string) => Promise<unknown>) | null;
|
||||
onSuccess: ((data: unknown) => void) | null;
|
||||
onError: ((err: unknown) => void) | null;
|
||||
} = { mutationFn: null, onSuccess: null, onError: null };
|
||||
|
||||
const lastMutateCall: { args: unknown[] } = { args: [] };
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: (opts: {
|
||||
mutationFn: (version: string) => Promise<unknown>;
|
||||
onSuccess?: (data: unknown) => void;
|
||||
onError?: (err: unknown) => void;
|
||||
}) => {
|
||||
mutationCallbacks.mutationFn = opts.mutationFn;
|
||||
mutationCallbacks.onSuccess = opts.onSuccess ?? null;
|
||||
mutationCallbacks.onError = opts.onError ?? null;
|
||||
return {
|
||||
mutate: (version: string) => {
|
||||
lastMutateCall.args = [version];
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.4.27 R4 RC2 — the dialog now mounts <ResponsiveSheet>. Its
|
||||
// internals (Sheet on `<md`, Dialog on `md+`) both portal at runtime;
|
||||
// we collapse the primitive to a plain wrapper so the body of the
|
||||
// dialog stays reachable in the static markup.
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-slot="mock-responsive-sheet">
|
||||
<div data-slot="mock-responsive-sheet-header">
|
||||
<h2 data-slot="mock-responsive-sheet-title">{title}</h2>
|
||||
{description ? (
|
||||
<p data-slot="mock-responsive-sheet-description">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div data-slot="mock-responsive-sheet-body">{children}</div>
|
||||
{footer ? (
|
||||
<div data-slot="mock-responsive-sheet-footer">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { ResearchModeAcknowledgmentDialog } from "../research-mode-acknowledgment-dialog";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mutationCallbacks.mutationFn = null;
|
||||
mutationCallbacks.onSuccess = null;
|
||||
mutationCallbacks.onError = null;
|
||||
lastMutateCall.args = [];
|
||||
vi.mocked(toast.success).mockClear();
|
||||
vi.mocked(toast.error).mockClear();
|
||||
});
|
||||
|
||||
describe("<ResearchModeAcknowledgmentDialog>", () => {
|
||||
it("renders nothing while `open === false`", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={false}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the title + the five MDR-copy sections while open", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Title + primary CTAs.
|
||||
expect(html).toContain("Enable Research Mode");
|
||||
expect(html).toContain("I understand — enable");
|
||||
expect(html).toContain("Cancel");
|
||||
|
||||
// Each of the five sections renders its own headline.
|
||||
expect(html).toContain("What this is");
|
||||
expect(html).toContain("What this isn't");
|
||||
expect(html).toContain("Why it's an estimate");
|
||||
expect(html).toContain("Regulatory boundary");
|
||||
expect(html).toContain("Sources");
|
||||
|
||||
// Cited regulatory references must appear verbatim in the
|
||||
// user-facing copy (project-voice + research §11 + §12.4).
|
||||
expect(html).toContain("2017/745");
|
||||
expect(html).toContain("MDCG 2021-24");
|
||||
// Peer-reviewed citation.
|
||||
expect(html).toContain("10.1002/psp4.13099");
|
||||
|
||||
// Version line surfaces the live server value.
|
||||
expect(html).toContain("Disclaimer version: 2026-05-14.1");
|
||||
});
|
||||
|
||||
it("acknowledge CTA invokes the mutation with the supplied version", async () => {
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// SSR markup can't fire DOM events; invoke the mutation directly
|
||||
// (the production component calls `mutation.mutate(version)` from
|
||||
// the click handler with `currentDisclaimerVersion`).
|
||||
expect(mutationCallbacks.mutationFn).not.toBeNull();
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ data: { enabled: true } }),
|
||||
// The apiFetch wrapper reads the envelope via `text()`.
|
||||
text: async () => JSON.stringify({ data: { enabled: true } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await mutationCallbacks.mutationFn?.("2026-05-14.1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/auth/me/research-mode");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
acknowledged: true,
|
||||
version: "2026-05-14.1",
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("onSuccess fires the localised success toast", () => {
|
||||
const onAcknowledged = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
onAcknowledged={onAcknowledged}
|
||||
/>,
|
||||
);
|
||||
|
||||
mutationCallbacks.onSuccess?.({ enabled: true });
|
||||
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.success).mock.calls[0][0]).toContain(
|
||||
"Research Mode enabled",
|
||||
);
|
||||
expect(onAcknowledged).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("stale-version error surfaces the localised error toast and keeps the dialog open", () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
mutationCallbacks.onError?.(new Error("research-mode.version.stale"));
|
||||
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.error).mock.calls[0][0]).toContain(
|
||||
"disclaimer was updated",
|
||||
);
|
||||
// The error path never closes the dialog — the caller stays put
|
||||
// so the user can reload and re-read the new copy.
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("generic error path surfaces the generic error toast", () => {
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
mutationCallbacks.onError?.(new Error("http-500"));
|
||||
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.error).mock.calls[0][0]).toContain(
|
||||
"Could not enable Research Mode",
|
||||
);
|
||||
});
|
||||
|
||||
it("acknowledge CTA is disabled until the server-supplied version arrives", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The disabled flag stamps as `disabled=""` on a server-rendered button.
|
||||
// The acknowledge button carries `data-slot="research-mode-acknowledge"`.
|
||||
const acknowledgeFragment = html.match(
|
||||
/<button[^>]*data-slot="research-mode-acknowledge"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(acknowledgeFragment).toBeDefined();
|
||||
expect(acknowledgeFragment).toMatch(/\sdisabled(="|\s|>)/);
|
||||
|
||||
// The version line is hidden when null — guard against leaking
|
||||
// `version: null` text into the UI.
|
||||
expect(html).not.toContain("Disclaimer version:");
|
||||
});
|
||||
|
||||
it("renders German copy when locale='de'", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Forschungsmodus aktivieren");
|
||||
expect(html).toContain("Verstanden — aktivieren");
|
||||
expect(html).toContain("Abbrechen");
|
||||
expect(html).toContain("2017/745");
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19c-Frontend — Research-mode MDR acknowledgment dialog.
|
||||
*
|
||||
* Opt-in gate in front of the GLP-1 drug-level chart. The chart paints
|
||||
* a unit-less Bateman PK curve over the user's logged intakes (research
|
||||
* §2.3 / §9.6.7). Because a "drug-level" surface is the brightest EU
|
||||
* MDR Class IIa edge in HealthLog's surface area (research §11 + §12.4),
|
||||
* the chart never paints until the user has explicitly read and
|
||||
* acknowledged the disclaimer below. The acknowledgment record is
|
||||
* server-stamped with `RESEARCH_MODE_DISCLAIMER_VERSION`; if the copy
|
||||
* changes the user re-acknowledges before the chart unhides.
|
||||
*
|
||||
* The dialog is rendered controlled by the Settings toggle and the
|
||||
* "re-prompt" banner (commit 3 of this phase). It accepts:
|
||||
* - `open` / `onOpenChange` for full external control
|
||||
* - `currentDisclaimerVersion` — the server's authoritative version
|
||||
* string. Acknowledgment posts THIS value, not the imported
|
||||
* constant — server redeploys that bump the constant must be
|
||||
* reflected without a client reload (W19c-Backend phase report).
|
||||
* - `onAcknowledged()` — fires after a successful 200; the caller
|
||||
* re-fetches the research-mode state and renders the toggle in
|
||||
* the new state.
|
||||
*
|
||||
* Copy direction (project-voice, English, no AI/phase/wave mentions):
|
||||
* - "What this is" — single-paragraph framing: the chart is an
|
||||
* estimate from EMA-published population PK.
|
||||
* - "What this isn't" — explicit boundary: not a measurement, not
|
||||
* personalised, not medical advice.
|
||||
* - "Why it's an estimate" — population PK + IIV; per-patient
|
||||
* variation is large.
|
||||
* - "MDR boundary" — cite EU 2017/745 and MDCG 2021-24 verbatim.
|
||||
* - "Citations" — EMA EPAR + Schneck/Urva 2024 (ASCPT psp4.13099).
|
||||
*
|
||||
* Toast surface uses sonner (the project convention) so the user gets
|
||||
* a transient confirmation on either success or rejection.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, BookOpenCheck, Loader2 } from "lucide-react";
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface ResearchModeAcknowledgmentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
/**
|
||||
* The version string from `GET /api/auth/me/research-mode`. The
|
||||
* server compares it byte-for-byte against the current constant; a
|
||||
* stale value forces a 400 from the API which we surface as an
|
||||
* error toast and keep the dialog open. Pass `null` while the
|
||||
* GET is in flight; the Acknowledge CTA stays disabled.
|
||||
*/
|
||||
currentDisclaimerVersion: string | null;
|
||||
/**
|
||||
* Fires after the POST returns 200. Caller invalidates the
|
||||
* `research-mode` query so the toggle reflects the new state.
|
||||
*/
|
||||
onAcknowledged?: () => void;
|
||||
}
|
||||
|
||||
export function ResearchModeAcknowledgmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentDisclaimerVersion,
|
||||
onAcknowledged,
|
||||
}: ResearchModeAcknowledgmentDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (version: string) => {
|
||||
// ApiError.message carries the envelope `error` string verbatim, so
|
||||
// the stale-version code below still matches on `err.message`.
|
||||
return (
|
||||
(await apiPost("/api/auth/me/research-mode", {
|
||||
acknowledged: true,
|
||||
version,
|
||||
})) ?? null
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setErrorMessage(null);
|
||||
toast.success(t("medications.researchMode.dialog.successToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
|
||||
onAcknowledged?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
// The 400 stale-version path is the only one we surface
|
||||
// explicitly — every other failure mode (401, 429, 500, network)
|
||||
// collapses to a generic error so the dialog stays open but the
|
||||
// user knows the acknowledgment didn't persist.
|
||||
const code = err instanceof Error ? err.message : "unknown";
|
||||
const isStale = code === "research-mode.version.stale";
|
||||
const message = isStale
|
||||
? t("medications.researchMode.dialog.staleVersionToast")
|
||||
: t("medications.researchMode.dialog.errorToast");
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
function handleAcknowledge() {
|
||||
if (!currentDisclaimerVersion) return;
|
||||
mutation.mutate(currentDisclaimerVersion);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setErrorMessage(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<BookOpenCheck className="text-foreground h-5 w-5 shrink-0" />
|
||||
{t("medications.researchMode.dialog.title")}
|
||||
</span>
|
||||
}
|
||||
description={t("medications.researchMode.dialog.intro")}
|
||||
className="sm:max-w-xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={mutation.isPending}
|
||||
data-slot="research-mode-cancel"
|
||||
>
|
||||
{t("medications.researchMode.dialog.cancelCta")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAcknowledge}
|
||||
disabled={mutation.isPending || !currentDisclaimerVersion}
|
||||
data-slot="research-mode-acknowledge"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("medications.researchMode.dialog.acknowledgeCta")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="research-mode-acknowledgment-dialog"
|
||||
className="space-y-4 text-sm"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="research-mode-what-it-is"
|
||||
data-slot="research-mode-what-it-is"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-what-it-is"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.whatItIsHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.whatItIs")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-what-it-isnt"
|
||||
data-slot="research-mode-what-it-isnt"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-what-it-isnt"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.whatItIsntHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.whatItIsnt")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-why-estimate"
|
||||
data-slot="research-mode-why-estimate"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-why-estimate"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.whyEstimateHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.whyEstimate")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-mdr"
|
||||
data-slot="research-mode-mdr-boundary"
|
||||
className="bg-warning/10 border-warning/30 rounded-md border-l-4 px-3 py-2"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-mdr"
|
||||
className="text-foreground flex items-center gap-2 font-medium"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-warning h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("medications.researchMode.dialog.mdrBoundaryHeader")}
|
||||
</h3>
|
||||
<p className="text-foreground/85 mt-1">
|
||||
{t("medications.researchMode.dialog.mdrBoundary")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-citations"
|
||||
data-slot="research-mode-citations"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-citations"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.citationsHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.citations")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{currentDisclaimerVersion && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs italic"
|
||||
data-slot="research-mode-version"
|
||||
>
|
||||
{t("medications.researchMode.dialog.versionLine", {
|
||||
version: currentDisclaimerVersion,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-slot="research-mode-error"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,6 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
|
||||
ResearchModeAcknowledgmentDialog: () => <div data-slot="mock-ack-dialog" />,
|
||||
}));
|
||||
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19c-Frontend — `<AdvancedSection>` Research Mode card tests.
|
||||
*
|
||||
* The card lives next to the danger-zone delete and drives three
|
||||
* server interactions:
|
||||
*
|
||||
* GET /api/auth/me/research-mode on mount, via TanStack Query
|
||||
* POST /api/auth/me/research-mode via the acknowledgment dialog
|
||||
* DELETE /api/auth/me/research-mode directly when the user
|
||||
* toggles OFF
|
||||
*
|
||||
* The toggle ON path opens the dialog; only the dialog's CTA fires
|
||||
* the POST (so the user reads the disclaimer first). OFF fires
|
||||
* DELETE directly. A version-mismatch banner renders above the
|
||||
* toggle whenever `acknowledgedVersion !== currentDisclaimerVersion`
|
||||
* even when `enabled === true`.
|
||||
*
|
||||
* The dialog itself is covered by
|
||||
* `research-mode-acknowledgment-dialog.test.tsx`; we mock it down to a
|
||||
* marker here so the toggle behaviour is the only contract under
|
||||
* test.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const queryResults: Record<string, unknown> = {};
|
||||
|
||||
function setQueryResult(keyJoined: string, data: unknown) {
|
||||
queryResults[keyJoined] = data;
|
||||
}
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = (queryKey as Array<string | number>).join("/");
|
||||
return {
|
||||
data: queryResults[key],
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// The acknowledgment dialog is exercised in its own spec; mock the
|
||||
// import to a transparent marker so we can assert "the toggle ON path
|
||||
// renders the dialog with the right props" without re-running the
|
||||
// dialog tree.
|
||||
vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
|
||||
ResearchModeAcknowledgmentDialog: ({
|
||||
open,
|
||||
currentDisclaimerVersion,
|
||||
}: {
|
||||
open: boolean;
|
||||
currentDisclaimerVersion: string | null;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="mock-ack-dialog"
|
||||
data-open={open ? "true" : "false"}
|
||||
data-version={currentDisclaimerVersion ?? ""}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(queryResults)) delete queryResults[k];
|
||||
});
|
||||
|
||||
describe("<AdvancedSection> → Research Mode card", () => {
|
||||
it("renders the section title + subtitle + toggle in OFF state", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
expect(html).toContain('data-slot="settings-research-mode-card"');
|
||||
expect(html).toContain("Research Mode");
|
||||
expect(html).toContain("Show the estimated drug-level chart");
|
||||
// OFF state status line.
|
||||
expect(html).toContain("Disabled. The drug-level chart is hidden.");
|
||||
// No re-prompt banner when versions already align (or the user has
|
||||
// never acknowledged in the first place).
|
||||
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
|
||||
// Switch is unchecked.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toBeDefined();
|
||||
expect(switchTag).not.toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("renders the acknowledged-on status when enabled + versions aligned", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-05-14T08:30:00Z",
|
||||
acknowledgedVersion: "2026-05-14.1",
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
// Acknowledged-on copy carries the formatted date (the i18n
|
||||
// template uses `{date}`; the formatter renders the day).
|
||||
expect(html).toMatch(/Acknowledged on/);
|
||||
// Banner stays hidden when versions match.
|
||||
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
|
||||
// Switch is checked.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("renders the re-prompt banner when versions diverge", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-04-01T00:00:00Z",
|
||||
acknowledgedVersion: "2026-04-01.1",
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
|
||||
expect(html).toContain("Disclaimer updated");
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt-cta"');
|
||||
expect(html).toContain("Re-acknowledge disclaimer");
|
||||
// Status line reflects the stale state without claiming
|
||||
// "acknowledged on …" — that copy would mis-represent that the
|
||||
// chart is currently painting.
|
||||
expect(html).not.toMatch(/Acknowledged on/);
|
||||
expect(html).toContain(
|
||||
"The disclaimer was updated. Re-acknowledge below to bring the chart back.",
|
||||
);
|
||||
// The toggle stays "on" — we surface the previous choice; the
|
||||
// banner explains why the chart isn't painting.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("forwards the current server-supplied version to the dialog", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-06-01.2",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
// The dialog renders even when closed (its `open` prop is what
|
||||
// controls visibility); the data-version attribute lets us verify
|
||||
// the parent passes the live version string through.
|
||||
expect(html).toContain('data-slot="mock-ack-dialog"');
|
||||
expect(html).toMatch(/data-version="2026-06-01\.2"/);
|
||||
expect(html).toMatch(/data-open="false"/);
|
||||
});
|
||||
|
||||
it("renders German copy when locale='de'", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />, "de");
|
||||
|
||||
expect(html).toContain("Forschungsmodus");
|
||||
expect(html).toContain("Geschätzte Wirkstoffkurve anzeigen");
|
||||
expect(html).toContain("Deaktiviert");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
* canonical column (`disableCoach` / `cycleTrackingEnabled`) via the existing
|
||||
* per-column endpoints — never the module-allowlist the gate ignores for them.
|
||||
*
|
||||
* Test strategy mirrors `advanced-research-mode.test.tsx`: mock
|
||||
* Test strategy mirrors `advanced-account-delete.test.tsx`: mock
|
||||
* `@tanstack/react-query` so the rendered tree executes under SSR
|
||||
* (`renderToStaticMarkup`), capture the `useMutation` config to assert the
|
||||
* PATCH body + the `authMe()` invalidation directly, and mock `useAuth` to
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -16,19 +16,10 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/research-mode-acknowledgment-dialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
type ResearchModeStatus,
|
||||
researchModeGateState,
|
||||
} from "@/lib/medications/research-mode-types";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
@@ -40,179 +31,28 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
* data" surface that should never live next to a single-click export
|
||||
* button.
|
||||
*
|
||||
* v1.4.25 W19c-Frontend adds the Research Mode toggle on this page.
|
||||
* Research Mode and the data-reset are both opt-in / version-gated
|
||||
* controls; they share a semantic shelf even though they touch
|
||||
* different parts of the user record. Toggle ON opens the
|
||||
* acknowledgment dialog (the user reads + confirms before the server
|
||||
* stamps the version). Toggle OFF fires DELETE directly. A separate
|
||||
* amber banner above the toggle catches the version-mismatch case
|
||||
* (server bumped the disclaimer while the user was still
|
||||
* acknowledged) and surfaces a Re-acknowledge CTA that re-opens the
|
||||
* dialog.
|
||||
* v1.4.25 added a Research Mode opt-in here, gating the estimated GLP-1
|
||||
* drug-level curve behind an acknowledged disclaimer version. The chart
|
||||
* stopped reading the flag some releases ago and has painted for everyone
|
||||
* since, so the toggle governed nothing: flipping it off changed no screen.
|
||||
* The curve is simply part of the medication page now, and the switch, its
|
||||
* acknowledgment dialog, the endpoint behind it and the three user columns
|
||||
* it stamped are gone. This page is the destructive controls.
|
||||
*/
|
||||
export function AdvancedSection() {
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the advanced cards.
|
||||
// v1.18.6.1 — the guided tour replay card was removed: the tour now exists
|
||||
// only as the first-time auto-start after onboarding. This page is the
|
||||
// research-mode + data/account destructive controls.
|
||||
// data/account destructive controls.
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ResearchModeCard />
|
||||
<DataResetCard />
|
||||
<AccountDeleteCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided
|
||||
* the toggle's caption out of the JSX. The four-way ladder (loading /
|
||||
* enabled-open / enabled-stale / disabled) is dense to read inline,
|
||||
* and the surface stays open to a fifth state ("never queried") when
|
||||
* we wire the offline shell in v1.4.26.
|
||||
*/
|
||||
function researchModeStatusLabel(
|
||||
status: ResearchModeStatus | null | undefined,
|
||||
isLoading: boolean,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
if (isLoading) return t("common.loading");
|
||||
const gate = researchModeGateState(status);
|
||||
if (gate === "off") return t("settings.researchMode.disabledStatus");
|
||||
if (gate === "stale") return t("settings.researchMode.enabledStaleStatus");
|
||||
return t("settings.researchMode.acknowledgedOn", {
|
||||
date: status?.acknowledgedAt ? formatDateTime(status.acknowledgedAt) : "—",
|
||||
});
|
||||
}
|
||||
|
||||
function ResearchModeCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [toggleBusy, setToggleBusy] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const { data: status, isLoading } = useQuery<ResearchModeStatus | null>({
|
||||
queryKey: queryKeys.researchMode(),
|
||||
queryFn: async () => {
|
||||
const res = await apiFetchRaw("/api/auth/me/research-mode");
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as ResearchModeStatus;
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const gateState = researchModeGateState(status);
|
||||
const showRePrompt = gateState === "stale";
|
||||
// The Switch's `checked` mirrors the server flag exactly — even when
|
||||
// the version is stale we keep the toggle "on" so the user sees that
|
||||
// their previous choice is preserved; the banner above the toggle
|
||||
// explains why the chart isn't painting.
|
||||
const switchChecked = !!status?.enabled;
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
if (toggleBusy) return;
|
||||
setErrorMessage(null);
|
||||
if (next) {
|
||||
// Toggle ON → open the acknowledgment dialog. The dialog owns
|
||||
// the POST; on success it invalidates the `research-mode` query
|
||||
// and the toggle reflects the new state.
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
// Toggle OFF → fire DELETE directly.
|
||||
setToggleBusy(true);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/auth/me/research-mode", {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
setErrorMessage(t("settings.researchMode.disableError"));
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
|
||||
} catch {
|
||||
setErrorMessage(t("settings.researchMode.disableError"));
|
||||
} finally {
|
||||
setToggleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-research-mode-card">
|
||||
<SettingsCardHeader
|
||||
icon={BookOpenCheck}
|
||||
title={t("settings.researchMode.sectionTitle")}
|
||||
description={t("settings.researchMode.subtitle")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.researchMode.subtitleDetail")}</p>
|
||||
|
||||
{showRePrompt && (
|
||||
<SettingsInfoTile
|
||||
icon={AlertTriangle}
|
||||
tone="warning"
|
||||
role="alert"
|
||||
data-slot="settings-research-mode-reprompt"
|
||||
title={t("settings.researchMode.rePromptTitle")}
|
||||
>
|
||||
<p>{t("settings.researchMode.rePromptBody")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2 min-h-11 sm:min-h-9"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
data-slot="settings-research-mode-reprompt-cta"
|
||||
>
|
||||
{t("settings.researchMode.rePromptCta")}
|
||||
</Button>
|
||||
</SettingsInfoTile>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.researchMode.toggleLabel")}
|
||||
</p>
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="settings-research-mode-status"
|
||||
>
|
||||
{researchModeStatusLabel(status, isLoading, t)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={switchChecked}
|
||||
disabled={isLoading || toggleBusy}
|
||||
onCheckedChange={(next) => {
|
||||
void handleToggle(next);
|
||||
}}
|
||||
aria-label={t("settings.researchMode.toggleLabel")}
|
||||
data-slot="settings-research-mode-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-slot="settings-research-mode-error"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
currentDisclaimerVersion={status?.currentDisclaimerVersion ?? null}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function DataResetCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -179,7 +179,9 @@ export function PrivacySection() {
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Privacy posture (AI privacy mode + research mode live on their pages) */}
|
||||
{/* Privacy posture. The AI privacy mode is what is left to decide here:
|
||||
research mode is gone, so the second button pointed at a page that
|
||||
no longer carries the control it named. */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
@@ -192,11 +194,6 @@ export function PrivacySection() {
|
||||
{t("settings.privacy.posture.openAi")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/advanced">
|
||||
{t("settings.privacy.posture.openResearch")}
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
|
||||
@@ -376,9 +376,6 @@ export const USER_RESET = {
|
||||
globalExcludedInjectionSites: [],
|
||||
moodReminderEnabled: false,
|
||||
notificationPrefs: Prisma.DbNull,
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
|
||||
// Back to the start of the tour — the record it was built around is gone
|
||||
onboardingCompletedAt: null,
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
GLP1_DRUG_IDS,
|
||||
type Glp1DrugId,
|
||||
} from "@/lib/medications/glp1-knowledge";
|
||||
import {
|
||||
computeOneCompartment,
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
shotPhaseAt,
|
||||
} from "@/lib/medications/glp1-pk";
|
||||
import { computeOneCompartment, shotPhaseAt } from "@/lib/medications/glp1-pk";
|
||||
|
||||
const ASOF = new Date("2026-05-14T12:00:00Z");
|
||||
|
||||
@@ -16,19 +12,6 @@ function hoursBefore(ms: Date, hours: number): Date {
|
||||
return new Date(ms.getTime() - hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
describe("RESEARCH_MODE_DISCLAIMER_VERSION", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof RESEARCH_MODE_DISCLAIMER_VERSION).toBe("string");
|
||||
expect(RESEARCH_MODE_DISCLAIMER_VERSION.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("matches the YYYY-MM-DD.N version stamp shape", () => {
|
||||
expect(RESEARCH_MODE_DISCLAIMER_VERSION).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}\.\d+$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeOneCompartment", () => {
|
||||
describe("per-drug smoke test", () => {
|
||||
// Iterate the catalog so the day a new EMA-approved drug
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
researchModeGateState,
|
||||
type ResearchModeStatus,
|
||||
} from "../research-mode-types";
|
||||
|
||||
const ACK_OPEN: ResearchModeStatus = {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-05-13T00:00:00.000Z",
|
||||
acknowledgedVersion: "v3",
|
||||
currentDisclaimerVersion: "v3",
|
||||
};
|
||||
|
||||
const ACK_STALE: ResearchModeStatus = {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-04-01T00:00:00.000Z",
|
||||
acknowledgedVersion: "v2",
|
||||
currentDisclaimerVersion: "v3",
|
||||
};
|
||||
|
||||
const OFF: ResearchModeStatus = {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "v3",
|
||||
};
|
||||
|
||||
describe("researchModeGateState", () => {
|
||||
it("returns 'off' when status is null (unauthenticated / loading)", () => {
|
||||
expect(researchModeGateState(null)).toBe("off");
|
||||
});
|
||||
|
||||
it("returns 'off' when status is undefined", () => {
|
||||
expect(researchModeGateState(undefined)).toBe("off");
|
||||
});
|
||||
|
||||
it("returns 'off' when the user never enabled Research Mode", () => {
|
||||
expect(researchModeGateState(OFF)).toBe("off");
|
||||
});
|
||||
|
||||
it("returns 'open' when enabled and acknowledged versions align", () => {
|
||||
expect(researchModeGateState(ACK_OPEN)).toBe("open");
|
||||
});
|
||||
|
||||
it("returns 'stale' when enabled but server bumped the disclaimer version", () => {
|
||||
expect(researchModeGateState(ACK_STALE)).toBe("stale");
|
||||
});
|
||||
|
||||
it("treats acknowledgedVersion === null as stale when enabled", () => {
|
||||
const status: ResearchModeStatus = {
|
||||
enabled: true,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "v3",
|
||||
};
|
||||
expect(researchModeGateState(status)).toBe("stale");
|
||||
});
|
||||
|
||||
it("never returns 'open' when enabled is false even if versions happen to match", () => {
|
||||
const status: ResearchModeStatus = {
|
||||
enabled: false,
|
||||
acknowledgedAt: "2026-05-13T00:00:00.000Z",
|
||||
acknowledgedVersion: "v3",
|
||||
currentDisclaimerVersion: "v3",
|
||||
};
|
||||
expect(researchModeGateState(status)).toBe("off");
|
||||
});
|
||||
});
|
||||
@@ -66,28 +66,6 @@
|
||||
|
||||
import { GLP1_DRUGS, type Glp1DrugId } from "@/lib/medications/glp1-knowledge";
|
||||
|
||||
/**
|
||||
* Version stamp of the MDR-disclaimer copy that the Research Mode
|
||||
* dialog displays. The API endpoint at
|
||||
* `/api/auth/me/research-mode` records this exact string on the
|
||||
* user row at acknowledgment time; the Settings UI re-prompts the
|
||||
* user when the persisted value drifts behind this constant.
|
||||
*
|
||||
* Format: `YYYY-MM-DD.N` — date the copy was finalised, plus a
|
||||
* single-digit counter so two updates on the same day stay
|
||||
* distinct.
|
||||
*
|
||||
* BUMP THIS STRING when:
|
||||
* - the disclaimer wording changes (any user-facing text edit),
|
||||
* - a new drug joins the catalog (the disclaimer enumerates them),
|
||||
* - the EMA EPAR cited as source changes version.
|
||||
*
|
||||
* Do NOT change it silently for cosmetic edits — every bump forces
|
||||
* every user to re-acknowledge on next chart open, which is the
|
||||
* desired behaviour but also visible friction.
|
||||
*/
|
||||
export const RESEARCH_MODE_DISCLAIMER_VERSION = "2026-05-14.1";
|
||||
|
||||
/**
|
||||
* A logged GLP-1 intake event reduced to the two fields the PK
|
||||
* math actually needs. The caller adapts from the project's richer
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W21 Fix-N — shared Research Mode status DTO.
|
||||
*
|
||||
* Both `<DrugLevelChart>` (medication detail page) and the Settings →
|
||||
* Advanced surface (`<AdvancedSection>`) query the same
|
||||
* `/api/auth/me/research-mode` endpoint to gate the drug-level chart on
|
||||
* acknowledged disclaimer version. Until Fix-N both surfaces hand-rolled
|
||||
* the same `ResearchModeStatus` interface; this module is now the single
|
||||
* source of truth so the two readers can never disagree on shape.
|
||||
*
|
||||
* Contract mirrors the API response under the `data` key:
|
||||
* { enabled, acknowledgedAt, acknowledgedVersion, currentDisclaimerVersion }
|
||||
*
|
||||
* `enabled === true && acknowledgedVersion === currentDisclaimerVersion`
|
||||
* is the only gate-open shape; every other permutation (off, stale-on)
|
||||
* renders the gated placeholder UI.
|
||||
*/
|
||||
export interface ResearchModeStatus {
|
||||
/** User opt-in flag — false unless the user has acknowledged at least once. */
|
||||
enabled: boolean;
|
||||
/** ISO timestamp of the most recent acknowledgment, null when never acknowledged. */
|
||||
acknowledgedAt: string | null;
|
||||
/** Disclaimer version the user last acknowledged; null when never acknowledged. */
|
||||
acknowledgedVersion: string | null;
|
||||
/** Disclaimer version the server is currently serving. Drives the re-prompt branch. */
|
||||
currentDisclaimerVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tri-state helper — narrow the raw status into one of the three UI
|
||||
* branches: off, stale (acknowledged-but-server-bumped-version), or
|
||||
* open. The DrugLevelChart and the Settings re-prompt banner both use
|
||||
* this so the rules stay aligned.
|
||||
*/
|
||||
export type ResearchModeGateState = "off" | "stale" | "open";
|
||||
|
||||
export function researchModeGateState(
|
||||
status: ResearchModeStatus | null | undefined,
|
||||
): ResearchModeGateState {
|
||||
if (!status || !status.enabled) return "off";
|
||||
if (status.acknowledgedVersion !== status.currentDisclaimerVersion) {
|
||||
return "stale";
|
||||
}
|
||||
return "open";
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* v1.18.6 (DISC-02) — the one-time medical-disclaimer acknowledgment version.
|
||||
*
|
||||
* Pinned per the `researchModeAcknowledged*` precedent: the user row stores
|
||||
* exactly which copy was acknowledged, so a future material wording change
|
||||
* can bump this constant to re-prompt. Keep it in lockstep with the
|
||||
* The user row stores exactly which copy was acknowledged, so a future
|
||||
* material wording change can bump this constant to re-prompt. Keep it in lockstep with the
|
||||
* `onboarding.disclaimer.*` i18n copy — bump on a substantive text change,
|
||||
* not on a typo fix.
|
||||
*/
|
||||
|
||||
@@ -52,7 +52,6 @@ export const settingsKeys = {
|
||||
/** v1.15.12 H1 — admin overview "update available" check against the
|
||||
* latest GitHub release tag (best-effort, day-stale). */
|
||||
versionUpdateCheck: () => ["version", "update-check"] as const,
|
||||
researchMode: () => ["research-mode"] as const,
|
||||
|
||||
/**
|
||||
* v1.23 — the Data & Privacy dashboard's read-only retention + encryption
|
||||
|
||||
Reference in new issue
Block a user