mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(coach,admin): finish the self-context and invite surfaces end to end
Invites move into their own admin section with a real ledger: status badges, expiry, uses, a redemption history of who joined when (new redemption table, revocation is now soft so history survives), and an honest one-time link/QR moment. The assistant self-context grows structured fields for conditions, allergies and focus — encrypted like the free text — merges age and gender from the profile into one fenced prompt block, and asks up to three targeted follow-up questions that surface as tappable chips above the coach composer. A fourth nudge trigger gently suggests a check-up when the self-context is stale and the coach is actually in use.
This commit is contained in:
1 parent
9fe5673251
commit
5b75184649
32 files changed
+2648
-423
No files matched your search
+57
-15
@@ -1957,6 +1957,8 @@
|
||||
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
|
||||
"composerHint": "Enter zum Senden, Umschalt+Enter für eine neue Zeile.",
|
||||
"composerDisclaimer": "Coach-Antworten sind generiert. Klinische Entscheidungen gehören in die Hand deines Arztes.",
|
||||
"selfContextChipsLabel": "Der Coach würde gern wissen:",
|
||||
"selfContextChipDismiss": "Frage verwerfen",
|
||||
"voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.",
|
||||
"threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.",
|
||||
"historyTitle": "Unterhaltungen",
|
||||
@@ -3823,7 +3825,18 @@
|
||||
"clearedToast": "Über-mich-Notiz gelöscht.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"loadError": "Die Notiz konnte nicht geladen werden.",
|
||||
"hint": "Wird als zusätzlicher Kontext an Coach und Tagesbriefing übergeben. Leer lassen, um nichts mitzusenden."
|
||||
"hint": "Wird als zusätzlicher Kontext an Coach und Tagesbriefing übergeben. Leer lassen, um nichts mitzusenden.",
|
||||
"profileHint": "Alter und Geschlecht stammen aus deinem Profil und werden automatisch mitgegeben.",
|
||||
"freeTextLabel": "Über mich (Freitext)",
|
||||
"conditionsLabel": "Chronische Erkrankungen",
|
||||
"conditionsPlaceholder": "Z. B. Bluthochdruck, Typ-2-Diabetes …",
|
||||
"allergiesLabel": "Allergien & Unverträglichkeiten",
|
||||
"allergiesPlaceholder": "Z. B. Laktoseintoleranz, Penicillin-Allergie …",
|
||||
"focusLabel": "Worauf soll der Coach achten?",
|
||||
"focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung …",
|
||||
"completeness": "{answered} von {total} beantwortet",
|
||||
"questionsTitle": "Der Coach hat Rückfragen",
|
||||
"questionsOpenCoach": "Im Coach beantworten"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -4307,32 +4320,51 @@
|
||||
"invites": {
|
||||
"title": "Einladungen",
|
||||
"description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung. Der Link wird nur einmal angezeigt.",
|
||||
"createLabel": "Neue Einladung:",
|
||||
"adminOnlyHint": "Nur Admins können Einladungen erstellen und widerrufen.",
|
||||
"create": "Einladung erstellen",
|
||||
"createTitle": "Einladung erstellen",
|
||||
"createDescription": "Der Link erlaubt eine Registrierung auch bei geschlossener Registrierung.",
|
||||
"ttlLegend": "Gültig für",
|
||||
"createDays": "{days} Tage",
|
||||
"maxUsesLabel": "Maximale Nutzungen",
|
||||
"maxUsesHint": "Wie viele Registrierungen dieser Link zulässt (1–50).",
|
||||
"createConfirm": "Einladung erstellen",
|
||||
"createdToast": "Einladung erstellt.",
|
||||
"createError": "Einladung konnte nicht erstellt werden.",
|
||||
"tokenTitle": "Einladungslink",
|
||||
"tokenOnceHint": "Wird nur jetzt angezeigt — kopiere den Link oder lass den QR-Code scannen.",
|
||||
"tokenOnceHint": "Nur jetzt kopierbar — kopiere den Link oder lass den QR-Code scannen.",
|
||||
"hashExplainer": "Gespeichert wird nur ein Hash des Links — er kann später nicht erneut angezeigt werden. Geht er verloren: Einladung widerrufen und neu erstellen.",
|
||||
"copyLink": "Link kopieren",
|
||||
"copiedToast": "Link kopiert.",
|
||||
"copyError": "Kopieren fehlgeschlagen.",
|
||||
"qrAlt": "QR-Code des Einladungslinks",
|
||||
"loadError": "Einladungen konnten nicht geladen werden.",
|
||||
"listEmpty": "Keine Einladungen vorhanden.",
|
||||
"expires": "läuft ab am {date}",
|
||||
"uses": "{used} / {max} genutzt",
|
||||
"createdMeta": "Erstellt am {date} von {by}",
|
||||
"usedBy": "zuletzt genutzt von {username}",
|
||||
"emptyTitle": "Noch keine Einladungen",
|
||||
"emptyDescription": "Erstelle einen Einladungslink, um jemanden trotz geschlossener Registrierung aufzunehmen.",
|
||||
"colStatus": "Status",
|
||||
"colCreated": "Erstellt",
|
||||
"colExpires": "Läuft ab",
|
||||
"colUses": "Nutzungen",
|
||||
"colRedeemedBy": "Eingelöst von",
|
||||
"colActions": "Aktionen",
|
||||
"byCreator": "von {by}",
|
||||
"expiresInDaysOne": "in 1 Tag",
|
||||
"expiresInDaysOther": "in {count} Tagen",
|
||||
"statusActive": "Aktiv",
|
||||
"statusExpired": "Abgelaufen",
|
||||
"statusExhausted": "Aufgebraucht",
|
||||
"deleteAria": "Einladung löschen",
|
||||
"deleteConfirmTitle": "Einladung löschen?",
|
||||
"deleteConfirmBody": "Der Einladungslink wird sofort ungültig. Bereits registrierte Konten bleiben bestehen.",
|
||||
"deleteConfirmAction": "Löschen",
|
||||
"statusRevoked": "Widerrufen",
|
||||
"redeemerDeleted": "gelöschtes Konto",
|
||||
"redeemedByMore": "{username} + {count} weitere",
|
||||
"redemptionsTitle": "{count} Einlösungen",
|
||||
"revoke": "Widerrufen",
|
||||
"revokeAria": "Einladung widerrufen",
|
||||
"revokeConfirmTitle": "Einladung widerrufen?",
|
||||
"revokeConfirmBody": "Der Einladungslink wird sofort ungültig. Bereits registrierte Konten bleiben bestehen, die Einlösungs-Historie bleibt sichtbar.",
|
||||
"revokeConfirmAction": "Widerrufen",
|
||||
"cancel": "Abbrechen",
|
||||
"deletedToast": "Einladung gelöscht.",
|
||||
"deleteError": "Einladung konnte nicht gelöscht werden."
|
||||
"revokedToast": "Einladung widerrufen.",
|
||||
"revokeError": "Einladung konnte nicht widerrufen werden."
|
||||
},
|
||||
"userManagement": "Benutzerverwaltung",
|
||||
"userName": "Name",
|
||||
@@ -4552,6 +4584,10 @@
|
||||
"emptyDescription": "Es gibt keine Nutzer, die zum aktuellen Filter passen.",
|
||||
"emptyResetFilter": "Alle Nutzer anzeigen"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Einladungen",
|
||||
"subtitle": "Einladungslinks erstellen, nachverfolgen und widerrufen."
|
||||
},
|
||||
"api-tokens": {
|
||||
"title": "API-Tokens",
|
||||
"subtitle": "Aktive Bearer-Tokens für native Clients und Integrationen."
|
||||
@@ -5199,7 +5235,13 @@
|
||||
"bpTitle": "Blutdruck im Wochenmittel erhöht",
|
||||
"bpBody": "Dein Blutdruck lag diese Woche im Schnitt über deinem Ziel. Der Coach kann die Werte mit dir einordnen.",
|
||||
"scoreTitle": "Dein Erholungswert fällt",
|
||||
"scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen."
|
||||
"scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen.",
|
||||
"selfContextTitle": "Hilf deinem Coach, dich besser zu kennen",
|
||||
"selfContextBody": "Deine Selbstauskunft ist unvollständig oder länger nicht aktualisiert. Zwei Minuten in den Einstellungen halten den Kontext des Coachs frisch.",
|
||||
"questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?",
|
||||
"questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?",
|
||||
"questionAllergies": "Hast du Allergien oder Unverträglichkeiten?",
|
||||
"questionAboutMe": "Magst du dem Coach etwas über dich erzählen — Alltag, Ziele, Kontext?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Geteilte Gesundheitsakte",
|
||||
|
||||
+57
-15
@@ -1957,6 +1957,8 @@
|
||||
"composerPlaceholder": "Ask anything about your data…",
|
||||
"composerHint": "Press Enter to send, Shift+Enter for a new line.",
|
||||
"composerDisclaimer": "Coach replies are generated. Clinical decisions belong with your doctor.",
|
||||
"selfContextChipsLabel": "The Coach would like to know:",
|
||||
"selfContextChipDismiss": "Dismiss question",
|
||||
"voiceComingSoon": "Voice input arrives with the iOS app in v1.5.",
|
||||
"threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.",
|
||||
"historyTitle": "Conversations",
|
||||
@@ -3823,7 +3825,18 @@
|
||||
"clearedToast": "About-me note cleared.",
|
||||
"saveError": "Could not save. Please try again.",
|
||||
"loadError": "The note could not be loaded.",
|
||||
"hint": "Passed to the Coach and the daily briefing as additional context. Leave empty to send nothing."
|
||||
"hint": "Passed to the Coach and the daily briefing as additional context. Leave empty to send nothing.",
|
||||
"profileHint": "Age and gender come from your profile and are included automatically.",
|
||||
"freeTextLabel": "About me (free text)",
|
||||
"conditionsLabel": "Chronic conditions",
|
||||
"conditionsPlaceholder": "E.g. hypertension, type 2 diabetes…",
|
||||
"allergiesLabel": "Allergies & intolerances",
|
||||
"allergiesPlaceholder": "E.g. lactose intolerance, penicillin allergy…",
|
||||
"focusLabel": "What should the Coach watch?",
|
||||
"focusPlaceholder": "E.g. keep an eye on my blood pressure, motivate me to move more…",
|
||||
"completeness": "{answered} of {total} answered",
|
||||
"questionsTitle": "The Coach has follow-up questions",
|
||||
"questionsOpenCoach": "Answer them in the Coach"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -4307,32 +4320,51 @@
|
||||
"invites": {
|
||||
"title": "Invites",
|
||||
"description": "Registration invite links — they work even while registration is closed. The link is shown only once.",
|
||||
"createLabel": "New invite:",
|
||||
"adminOnlyHint": "Only admins can create and revoke invites.",
|
||||
"create": "Create invite",
|
||||
"createTitle": "Create invite",
|
||||
"createDescription": "The link admits a signup even while registration is closed.",
|
||||
"ttlLegend": "Valid for",
|
||||
"createDays": "{days} days",
|
||||
"maxUsesLabel": "Maximum uses",
|
||||
"maxUsesHint": "How many signups this link admits (1–50).",
|
||||
"createConfirm": "Create invite",
|
||||
"createdToast": "Invite created.",
|
||||
"createError": "Could not create the invite.",
|
||||
"tokenTitle": "Invite link",
|
||||
"tokenOnceHint": "Shown only now — copy the link or have the QR code scanned.",
|
||||
"tokenOnceHint": "Copyable only now — copy the link or have the QR code scanned.",
|
||||
"hashExplainer": "Only a hash of the link is stored, so it cannot be shown again later. If it gets lost, revoke the invite and create a new one.",
|
||||
"copyLink": "Copy link",
|
||||
"copiedToast": "Link copied.",
|
||||
"copyError": "Copy failed.",
|
||||
"qrAlt": "QR code for the invite link",
|
||||
"loadError": "Could not load the invites.",
|
||||
"listEmpty": "No invites yet.",
|
||||
"expires": "expires {date}",
|
||||
"uses": "{used} / {max} used",
|
||||
"createdMeta": "Created {date} by {by}",
|
||||
"usedBy": "last used by {username}",
|
||||
"emptyTitle": "No invites yet",
|
||||
"emptyDescription": "Create an invite link to admit someone while registration is closed.",
|
||||
"colStatus": "Status",
|
||||
"colCreated": "Created",
|
||||
"colExpires": "Expires",
|
||||
"colUses": "Uses",
|
||||
"colRedeemedBy": "Redeemed by",
|
||||
"colActions": "Actions",
|
||||
"byCreator": "by {by}",
|
||||
"expiresInDaysOne": "in 1 day",
|
||||
"expiresInDaysOther": "in {count} days",
|
||||
"statusActive": "Active",
|
||||
"statusExpired": "Expired",
|
||||
"statusExhausted": "Used up",
|
||||
"deleteAria": "Delete invite",
|
||||
"deleteConfirmTitle": "Delete this invite?",
|
||||
"deleteConfirmBody": "The invite link becomes invalid immediately. Accounts already registered through it remain.",
|
||||
"deleteConfirmAction": "Delete",
|
||||
"statusRevoked": "Revoked",
|
||||
"redeemerDeleted": "deleted account",
|
||||
"redeemedByMore": "{username} + {count} more",
|
||||
"redemptionsTitle": "{count} redemptions",
|
||||
"revoke": "Revoke",
|
||||
"revokeAria": "Revoke invite",
|
||||
"revokeConfirmTitle": "Revoke this invite?",
|
||||
"revokeConfirmBody": "The invite link becomes invalid immediately. Accounts already registered through it remain, and the redemption history stays visible.",
|
||||
"revokeConfirmAction": "Revoke",
|
||||
"cancel": "Cancel",
|
||||
"deletedToast": "Invite deleted.",
|
||||
"deleteError": "Could not delete the invite."
|
||||
"revokedToast": "Invite revoked.",
|
||||
"revokeError": "Could not revoke the invite."
|
||||
},
|
||||
"userManagement": "User Management",
|
||||
"userName": "Name",
|
||||
@@ -4552,6 +4584,10 @@
|
||||
"emptyDescription": "There are no users matching the current filter.",
|
||||
"emptyResetFilter": "Show all users"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Invites",
|
||||
"subtitle": "Mint, track, and revoke registration invite links."
|
||||
},
|
||||
"api-tokens": {
|
||||
"title": "API Tokens",
|
||||
"subtitle": "Active bearer tokens issued to native clients and integrations."
|
||||
@@ -5199,7 +5235,13 @@
|
||||
"bpTitle": "Blood pressure elevated this week",
|
||||
"bpBody": "Your blood pressure averaged above your target this week. The Coach can help you put the readings in context.",
|
||||
"scoreTitle": "Your recovery score is dropping",
|
||||
"scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes."
|
||||
"scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes.",
|
||||
"selfContextTitle": "Help your Coach know you better",
|
||||
"selfContextBody": "Your self-description is incomplete or has not been updated in a while. Two minutes in the settings keep the Coach's context fresh.",
|
||||
"questionConditions": "Do you have any chronic conditions the Coach should know about?",
|
||||
"questionFocus": "Is there anything specific the Coach should pay attention to?",
|
||||
"questionAllergies": "Do you have any allergies or intolerances?",
|
||||
"questionAboutMe": "Want to tell the Coach a bit about yourself — routines, goals, context?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Shared health record",
|
||||
|
||||
+57
-15
@@ -1957,6 +1957,8 @@
|
||||
"composerPlaceholder": "Pregúntame sobre tus datos…",
|
||||
"composerHint": "Intro para enviar, Mayús+Intro para una nueva línea.",
|
||||
"composerDisclaimer": "Las respuestas son generadas. Las decisiones clínicas las toma tu médico.",
|
||||
"selfContextChipsLabel": "Al Coach le gustaría saber:",
|
||||
"selfContextChipDismiss": "Descartar pregunta",
|
||||
"voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.",
|
||||
"threadEmpty": "Pregúntame algo sobre tus tendencias, medicamentos o mediciones para empezar.",
|
||||
"historyTitle": "Conversaciones",
|
||||
@@ -3823,7 +3825,18 @@
|
||||
"clearedToast": "Nota «Sobre mí» eliminada.",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"loadError": "No se pudo cargar la nota.",
|
||||
"hint": "Se envía al Coach y al informe diario como contexto adicional. Déjalo vacío para no enviar nada."
|
||||
"hint": "Se envía al Coach y al informe diario como contexto adicional. Déjalo vacío para no enviar nada.",
|
||||
"profileHint": "La edad y el género provienen de tu perfil y se incluyen automáticamente.",
|
||||
"freeTextLabel": "Sobre mí (texto libre)",
|
||||
"conditionsLabel": "Enfermedades crónicas",
|
||||
"conditionsPlaceholder": "P. ej. hipertensión, diabetes tipo 2…",
|
||||
"allergiesLabel": "Alergias e intolerancias",
|
||||
"allergiesPlaceholder": "P. ej. intolerancia a la lactosa, alergia a la penicilina…",
|
||||
"focusLabel": "¿En qué debería fijarse el Coach?",
|
||||
"focusPlaceholder": "P. ej. vigila mi tensión arterial, motívame a moverme más…",
|
||||
"completeness": "{answered} de {total} respondidas",
|
||||
"questionsTitle": "El Coach tiene preguntas",
|
||||
"questionsOpenCoach": "Responderlas en el Coach"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -4307,32 +4320,51 @@
|
||||
"invites": {
|
||||
"title": "Invitaciones",
|
||||
"description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado. El enlace se muestra solo una vez.",
|
||||
"createLabel": "Nueva invitación:",
|
||||
"adminOnlyHint": "Solo los administradores pueden crear y revocar invitaciones.",
|
||||
"create": "Crear invitación",
|
||||
"createTitle": "Crear invitación",
|
||||
"createDescription": "El enlace permite registrarse incluso con el registro cerrado.",
|
||||
"ttlLegend": "Válida durante",
|
||||
"createDays": "{days} días",
|
||||
"maxUsesLabel": "Usos máximos",
|
||||
"maxUsesHint": "Cuántos registros admite este enlace (1–50).",
|
||||
"createConfirm": "Crear invitación",
|
||||
"createdToast": "Invitación creada.",
|
||||
"createError": "No se pudo crear la invitación.",
|
||||
"tokenTitle": "Enlace de invitación",
|
||||
"tokenOnceHint": "Se muestra solo ahora: copia el enlace o deja que escaneen el código QR.",
|
||||
"tokenOnceHint": "Solo se puede copiar ahora: copia el enlace o deja que escaneen el código QR.",
|
||||
"hashExplainer": "Solo se guarda un hash del enlace, por lo que no se puede volver a mostrar. Si se pierde, revoca la invitación y crea una nueva.",
|
||||
"copyLink": "Copiar enlace",
|
||||
"copiedToast": "Enlace copiado.",
|
||||
"copyError": "No se pudo copiar.",
|
||||
"qrAlt": "Código QR del enlace de invitación",
|
||||
"loadError": "No se pudieron cargar las invitaciones.",
|
||||
"listEmpty": "Todavía no hay invitaciones.",
|
||||
"expires": "caduca el {date}",
|
||||
"uses": "{used} / {max} usados",
|
||||
"createdMeta": "Creada el {date} por {by}",
|
||||
"usedBy": "usada por última vez por {username}",
|
||||
"emptyTitle": "Todavía no hay invitaciones",
|
||||
"emptyDescription": "Crea un enlace de invitación para admitir a alguien con el registro cerrado.",
|
||||
"colStatus": "Estado",
|
||||
"colCreated": "Creada",
|
||||
"colExpires": "Caduca",
|
||||
"colUses": "Usos",
|
||||
"colRedeemedBy": "Canjeada por",
|
||||
"colActions": "Acciones",
|
||||
"byCreator": "por {by}",
|
||||
"expiresInDaysOne": "en 1 día",
|
||||
"expiresInDaysOther": "en {count} días",
|
||||
"statusActive": "Activa",
|
||||
"statusExpired": "Caducada",
|
||||
"statusExhausted": "Agotada",
|
||||
"deleteAria": "Eliminar invitación",
|
||||
"deleteConfirmTitle": "¿Eliminar esta invitación?",
|
||||
"deleteConfirmBody": "El enlace de invitación dejará de ser válido de inmediato. Las cuentas ya registradas con él se mantienen.",
|
||||
"deleteConfirmAction": "Eliminar",
|
||||
"statusRevoked": "Revocada",
|
||||
"redeemerDeleted": "cuenta eliminada",
|
||||
"redeemedByMore": "{username} + {count} más",
|
||||
"redemptionsTitle": "{count} canjes",
|
||||
"revoke": "Revocar",
|
||||
"revokeAria": "Revocar invitación",
|
||||
"revokeConfirmTitle": "¿Revocar esta invitación?",
|
||||
"revokeConfirmBody": "El enlace de invitación dejará de ser válido de inmediato. Las cuentas ya registradas con él se mantienen y el historial de canjes sigue visible.",
|
||||
"revokeConfirmAction": "Revocar",
|
||||
"cancel": "Cancelar",
|
||||
"deletedToast": "Invitación eliminada.",
|
||||
"deleteError": "No se pudo eliminar la invitación."
|
||||
"revokedToast": "Invitación revocada.",
|
||||
"revokeError": "No se pudo revocar la invitación."
|
||||
},
|
||||
"userManagement": "Gestión de usuarios",
|
||||
"userName": "Nombre",
|
||||
@@ -4552,6 +4584,10 @@
|
||||
"emptyDescription": "No hay usuarios que coincidan con el filtro actual.",
|
||||
"emptyResetFilter": "Mostrar todos los usuarios"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Invitaciones",
|
||||
"subtitle": "Crear, seguir y revocar enlaces de invitación."
|
||||
},
|
||||
"api-tokens": {
|
||||
"title": "Tokens de API",
|
||||
"subtitle": "Tokens bearer activos emitidos a clientes nativos e integraciones."
|
||||
@@ -5199,7 +5235,13 @@
|
||||
"bpTitle": "Tensión elevada esta semana",
|
||||
"bpBody": "Tu tensión media de esta semana superó tu objetivo. El Coach puede ayudarte a poner los valores en contexto.",
|
||||
"scoreTitle": "Tu puntuación de recuperación baja",
|
||||
"scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas."
|
||||
"scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas.",
|
||||
"selfContextTitle": "Ayuda a tu Coach a conocerte mejor",
|
||||
"selfContextBody": "Tu autodescripción está incompleta o lleva tiempo sin actualizarse. Dos minutos en los ajustes mantienen fresco el contexto del Coach.",
|
||||
"questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?",
|
||||
"questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?",
|
||||
"questionAllergies": "¿Tienes alergias o intolerancias?",
|
||||
"questionAboutMe": "¿Quieres contarle al Coach algo sobre ti: rutinas, objetivos, contexto?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Registro de salud compartido",
|
||||
|
||||
+57
-15
@@ -1957,6 +1957,8 @@
|
||||
"composerPlaceholder": "Pose-moi une question sur tes données…",
|
||||
"composerHint": "Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne.",
|
||||
"composerDisclaimer": "Les réponses sont générées. Les décisions cliniques relèvent de ton médecin.",
|
||||
"selfContextChipsLabel": "Le Coach aimerait savoir :",
|
||||
"selfContextChipDismiss": "Ignorer la question",
|
||||
"voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.",
|
||||
"threadEmpty": "Pose une question sur tes tendances, médicaments ou mesures pour démarrer.",
|
||||
"historyTitle": "Conversations",
|
||||
@@ -3823,7 +3825,18 @@
|
||||
"clearedToast": "Note « À propos de moi » supprimée.",
|
||||
"saveError": "Impossible d'enregistrer. Veuillez réessayer.",
|
||||
"loadError": "Impossible de charger la note.",
|
||||
"hint": "Transmis au Coach et au briefing quotidien comme contexte supplémentaire. Laissez vide pour ne rien envoyer."
|
||||
"hint": "Transmis au Coach et au briefing quotidien comme contexte supplémentaire. Laissez vide pour ne rien envoyer.",
|
||||
"profileHint": "L'âge et le genre proviennent de votre profil et sont inclus automatiquement.",
|
||||
"freeTextLabel": "À propos de moi (texte libre)",
|
||||
"conditionsLabel": "Maladies chroniques",
|
||||
"conditionsPlaceholder": "P. ex. hypertension, diabète de type 2…",
|
||||
"allergiesLabel": "Allergies et intolérances",
|
||||
"allergiesPlaceholder": "P. ex. intolérance au lactose, allergie à la pénicilline…",
|
||||
"focusLabel": "À quoi le Coach doit-il veiller ?",
|
||||
"focusPlaceholder": "P. ex. surveille ma tension, motive-moi à bouger davantage…",
|
||||
"completeness": "{answered} sur {total} renseignés",
|
||||
"questionsTitle": "Le Coach a des questions",
|
||||
"questionsOpenCoach": "Y répondre dans le Coach"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -4307,32 +4320,51 @@
|
||||
"invites": {
|
||||
"title": "Invitations",
|
||||
"description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées. Le lien n'est affiché qu'une seule fois.",
|
||||
"createLabel": "Nouvelle invitation :",
|
||||
"adminOnlyHint": "Seuls les administrateurs peuvent créer et révoquer des invitations.",
|
||||
"create": "Créer une invitation",
|
||||
"createTitle": "Créer une invitation",
|
||||
"createDescription": "Le lien permet une inscription même lorsque les inscriptions sont fermées.",
|
||||
"ttlLegend": "Valable pendant",
|
||||
"createDays": "{days} jours",
|
||||
"maxUsesLabel": "Utilisations maximales",
|
||||
"maxUsesHint": "Nombre d'inscriptions que ce lien autorise (1–50).",
|
||||
"createConfirm": "Créer l'invitation",
|
||||
"createdToast": "Invitation créée.",
|
||||
"createError": "Impossible de créer l'invitation.",
|
||||
"tokenTitle": "Lien d'invitation",
|
||||
"tokenOnceHint": "Affiché uniquement maintenant — copiez le lien ou faites scanner le code QR.",
|
||||
"tokenOnceHint": "Copiable uniquement maintenant — copiez le lien ou faites scanner le code QR.",
|
||||
"hashExplainer": "Seul un hachage du lien est stocké : il ne pourra plus être affiché. En cas de perte, révoquez l'invitation et créez-en une nouvelle.",
|
||||
"copyLink": "Copier le lien",
|
||||
"copiedToast": "Lien copié.",
|
||||
"copyError": "Échec de la copie.",
|
||||
"qrAlt": "Code QR du lien d'invitation",
|
||||
"loadError": "Impossible de charger les invitations.",
|
||||
"listEmpty": "Aucune invitation pour l'instant.",
|
||||
"expires": "expire le {date}",
|
||||
"uses": "{used} / {max} utilisés",
|
||||
"createdMeta": "Créée le {date} par {by}",
|
||||
"usedBy": "utilisée en dernier par {username}",
|
||||
"emptyTitle": "Aucune invitation pour l'instant",
|
||||
"emptyDescription": "Créez un lien d'invitation pour admettre quelqu'un malgré des inscriptions fermées.",
|
||||
"colStatus": "Statut",
|
||||
"colCreated": "Créée",
|
||||
"colExpires": "Expire",
|
||||
"colUses": "Utilisations",
|
||||
"colRedeemedBy": "Utilisée par",
|
||||
"colActions": "Actions",
|
||||
"byCreator": "par {by}",
|
||||
"expiresInDaysOne": "dans 1 jour",
|
||||
"expiresInDaysOther": "dans {count} jours",
|
||||
"statusActive": "Active",
|
||||
"statusExpired": "Expirée",
|
||||
"statusExhausted": "Épuisée",
|
||||
"deleteAria": "Supprimer l'invitation",
|
||||
"deleteConfirmTitle": "Supprimer cette invitation ?",
|
||||
"deleteConfirmBody": "Le lien d'invitation devient immédiatement invalide. Les comptes déjà créés avec ce lien sont conservés.",
|
||||
"deleteConfirmAction": "Supprimer",
|
||||
"statusRevoked": "Révoquée",
|
||||
"redeemerDeleted": "compte supprimé",
|
||||
"redeemedByMore": "{username} + {count} autres",
|
||||
"redemptionsTitle": "{count} utilisations",
|
||||
"revoke": "Révoquer",
|
||||
"revokeAria": "Révoquer l'invitation",
|
||||
"revokeConfirmTitle": "Révoquer cette invitation ?",
|
||||
"revokeConfirmBody": "Le lien d'invitation devient immédiatement invalide. Les comptes déjà créés avec ce lien sont conservés et l'historique des utilisations reste visible.",
|
||||
"revokeConfirmAction": "Révoquer",
|
||||
"cancel": "Annuler",
|
||||
"deletedToast": "Invitation supprimée.",
|
||||
"deleteError": "Impossible de supprimer l'invitation."
|
||||
"revokedToast": "Invitation révoquée.",
|
||||
"revokeError": "Impossible de révoquer l'invitation."
|
||||
},
|
||||
"userManagement": "Gestion des utilisateurs",
|
||||
"userName": "Nom",
|
||||
@@ -4552,6 +4584,10 @@
|
||||
"emptyDescription": "Aucun utilisateur ne correspond au filtre actuel.",
|
||||
"emptyResetFilter": "Afficher tous les utilisateurs"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Invitations",
|
||||
"subtitle": "Créer, suivre et révoquer les liens d'invitation."
|
||||
},
|
||||
"api-tokens": {
|
||||
"title": "Jetons d'API",
|
||||
"subtitle": "Jetons bearer actifs émis aux clients natifs et aux intégrations."
|
||||
@@ -5199,7 +5235,13 @@
|
||||
"bpTitle": "Tension élevée cette semaine",
|
||||
"bpBody": "Votre tension moyenne de la semaine a dépassé votre objectif. Le Coach peut vous aider à mettre les valeurs en perspective.",
|
||||
"scoreTitle": "Votre score de récupération baisse",
|
||||
"scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles."
|
||||
"scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles.",
|
||||
"selfContextTitle": "Aidez votre Coach à mieux vous connaître",
|
||||
"selfContextBody": "Votre auto-description est incomplète ou n'a pas été mise à jour depuis un moment. Deux minutes dans les réglages gardent le contexte du Coach à jour.",
|
||||
"questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?",
|
||||
"questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?",
|
||||
"questionAllergies": "Avez-vous des allergies ou des intolérances ?",
|
||||
"questionAboutMe": "Envie de parler un peu de vous au Coach — habitudes, objectifs, contexte ?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Dossier de santé partagé",
|
||||
|
||||
+57
-15
@@ -1957,6 +1957,8 @@
|
||||
"composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…",
|
||||
"composerHint": "Invio per inviare, Shift+Invio per una nuova riga.",
|
||||
"composerDisclaimer": "Le risposte sono generate. Le decisioni cliniche restano al tuo medico.",
|
||||
"selfContextChipsLabel": "Il Coach vorrebbe sapere:",
|
||||
"selfContextChipDismiss": "Ignora la domanda",
|
||||
"voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.",
|
||||
"threadEmpty": "Chiedimi qualcosa sui tuoi trend, farmaci o misurazioni per iniziare.",
|
||||
"historyTitle": "Conversazioni",
|
||||
@@ -3823,7 +3825,18 @@
|
||||
"clearedToast": "Nota «Su di me» eliminata.",
|
||||
"saveError": "Impossibile salvare. Riprova.",
|
||||
"loadError": "Impossibile caricare la nota.",
|
||||
"hint": "Inviato al Coach e al briefing giornaliero come contesto aggiuntivo. Lascia vuoto per non inviare nulla."
|
||||
"hint": "Inviato al Coach e al briefing giornaliero come contesto aggiuntivo. Lascia vuoto per non inviare nulla.",
|
||||
"profileHint": "Età e genere provengono dal tuo profilo e vengono inclusi automaticamente.",
|
||||
"freeTextLabel": "Su di me (testo libero)",
|
||||
"conditionsLabel": "Malattie croniche",
|
||||
"conditionsPlaceholder": "Es. ipertensione, diabete di tipo 2…",
|
||||
"allergiesLabel": "Allergie e intolleranze",
|
||||
"allergiesPlaceholder": "Es. intolleranza al lattosio, allergia alla penicillina…",
|
||||
"focusLabel": "A cosa deve fare attenzione il Coach?",
|
||||
"focusPlaceholder": "Es. tieni d'occhio la mia pressione, motivami a muovermi di più…",
|
||||
"completeness": "{answered} su {total} compilati",
|
||||
"questionsTitle": "Il Coach ha alcune domande",
|
||||
"questionsOpenCoach": "Rispondi nel Coach"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -4307,32 +4320,51 @@
|
||||
"invites": {
|
||||
"title": "Inviti",
|
||||
"description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa. Il link viene mostrato una sola volta.",
|
||||
"createLabel": "Nuovo invito:",
|
||||
"adminOnlyHint": "Solo gli amministratori possono creare e revocare gli inviti.",
|
||||
"create": "Crea invito",
|
||||
"createTitle": "Crea invito",
|
||||
"createDescription": "Il link consente una registrazione anche quando la registrazione è chiusa.",
|
||||
"ttlLegend": "Valido per",
|
||||
"createDays": "{days} giorni",
|
||||
"maxUsesLabel": "Utilizzi massimi",
|
||||
"maxUsesHint": "Quante registrazioni consente questo link (1–50).",
|
||||
"createConfirm": "Crea invito",
|
||||
"createdToast": "Invito creato.",
|
||||
"createError": "Impossibile creare l'invito.",
|
||||
"tokenTitle": "Link di invito",
|
||||
"tokenOnceHint": "Mostrato solo adesso: copia il link o fai scansionare il codice QR.",
|
||||
"tokenOnceHint": "Copiabile solo adesso: copia il link o fai scansionare il codice QR.",
|
||||
"hashExplainer": "Viene salvato solo un hash del link: non potrà più essere mostrato. Se va perso, revoca l'invito e creane uno nuovo.",
|
||||
"copyLink": "Copia link",
|
||||
"copiedToast": "Link copiato.",
|
||||
"copyError": "Copia non riuscita.",
|
||||
"qrAlt": "Codice QR del link di invito",
|
||||
"loadError": "Impossibile caricare gli inviti.",
|
||||
"listEmpty": "Ancora nessun invito.",
|
||||
"expires": "scade il {date}",
|
||||
"uses": "{used} / {max} usati",
|
||||
"createdMeta": "Creato il {date} da {by}",
|
||||
"usedBy": "usato per ultimo da {username}",
|
||||
"emptyTitle": "Ancora nessun invito",
|
||||
"emptyDescription": "Crea un link di invito per ammettere qualcuno nonostante la registrazione chiusa.",
|
||||
"colStatus": "Stato",
|
||||
"colCreated": "Creato",
|
||||
"colExpires": "Scade",
|
||||
"colUses": "Utilizzi",
|
||||
"colRedeemedBy": "Riscattato da",
|
||||
"colActions": "Azioni",
|
||||
"byCreator": "da {by}",
|
||||
"expiresInDaysOne": "tra 1 giorno",
|
||||
"expiresInDaysOther": "tra {count} giorni",
|
||||
"statusActive": "Attivo",
|
||||
"statusExpired": "Scaduto",
|
||||
"statusExhausted": "Esaurito",
|
||||
"deleteAria": "Elimina invito",
|
||||
"deleteConfirmTitle": "Eliminare questo invito?",
|
||||
"deleteConfirmBody": "Il link di invito diventa subito non valido. Gli account già registrati con questo link restano.",
|
||||
"deleteConfirmAction": "Elimina",
|
||||
"statusRevoked": "Revocato",
|
||||
"redeemerDeleted": "account eliminato",
|
||||
"redeemedByMore": "{username} + altri {count}",
|
||||
"redemptionsTitle": "{count} riscatti",
|
||||
"revoke": "Revoca",
|
||||
"revokeAria": "Revoca invito",
|
||||
"revokeConfirmTitle": "Revocare questo invito?",
|
||||
"revokeConfirmBody": "Il link di invito diventa subito non valido. Gli account già registrati con questo link restano e la cronologia dei riscatti resta visibile.",
|
||||
"revokeConfirmAction": "Revoca",
|
||||
"cancel": "Annulla",
|
||||
"deletedToast": "Invito eliminato.",
|
||||
"deleteError": "Impossibile eliminare l'invito."
|
||||
"revokedToast": "Invito revocato.",
|
||||
"revokeError": "Impossibile revocare l'invito."
|
||||
},
|
||||
"userManagement": "Gestione utenti",
|
||||
"userName": "Nome",
|
||||
@@ -4552,6 +4584,10 @@
|
||||
"emptyDescription": "Nessun utente corrisponde al filtro attuale.",
|
||||
"emptyResetFilter": "Mostra tutti gli utenti"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Inviti",
|
||||
"subtitle": "Creare, monitorare e revocare i link di invito."
|
||||
},
|
||||
"api-tokens": {
|
||||
"title": "Token API",
|
||||
"subtitle": "Token bearer attivi emessi a client nativi e integrazioni."
|
||||
@@ -5199,7 +5235,13 @@
|
||||
"bpTitle": "Pressione elevata questa settimana",
|
||||
"bpBody": "La tua pressione media di questa settimana ha superato il tuo obiettivo. Il Coach può aiutarti a contestualizzare i valori.",
|
||||
"scoreTitle": "Il tuo punteggio di recupero sta calando",
|
||||
"scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause."
|
||||
"scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause.",
|
||||
"selfContextTitle": "Aiuta il tuo Coach a conoscerti meglio",
|
||||
"selfContextBody": "La tua autodescrizione è incompleta o non viene aggiornata da tempo. Due minuti nelle impostazioni mantengono fresco il contesto del Coach.",
|
||||
"questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?",
|
||||
"questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?",
|
||||
"questionAllergies": "Hai allergie o intolleranze?",
|
||||
"questionAboutMe": "Vuoi raccontare al Coach qualcosa di te — abitudini, obiettivi, contesto?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Cartella sanitaria condivisa",
|
||||
|
||||
+57
-15
@@ -1957,6 +1957,8 @@
|
||||
"composerPlaceholder": "Zapytaj o swoje dane…",
|
||||
"composerHint": "Enter wysyła, Shift+Enter przechodzi do nowej linii.",
|
||||
"composerDisclaimer": "Odpowiedzi są generowane. Decyzje kliniczne należą do lekarza.",
|
||||
"selfContextChipsLabel": "Coach chciałby wiedzieć:",
|
||||
"selfContextChipDismiss": "Odrzuć pytanie",
|
||||
"voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.",
|
||||
"threadEmpty": "Zapytaj o swoje trendy, leki lub pomiary, aby zacząć.",
|
||||
"historyTitle": "Rozmowy",
|
||||
@@ -3823,7 +3825,18 @@
|
||||
"clearedToast": "Notatka „O mnie” usunięta.",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"loadError": "Nie udało się wczytać notatki.",
|
||||
"hint": "Przekazywane Coachowi i codziennemu briefingowi jako dodatkowy kontekst. Zostaw puste, aby nic nie wysyłać."
|
||||
"hint": "Przekazywane Coachowi i codziennemu briefingowi jako dodatkowy kontekst. Zostaw puste, aby nic nie wysyłać.",
|
||||
"profileHint": "Wiek i płeć pochodzą z Twojego profilu i są dołączane automatycznie.",
|
||||
"freeTextLabel": "O mnie (tekst dowolny)",
|
||||
"conditionsLabel": "Choroby przewlekłe",
|
||||
"conditionsPlaceholder": "Np. nadciśnienie, cukrzyca typu 2…",
|
||||
"allergiesLabel": "Alergie i nietolerancje",
|
||||
"allergiesPlaceholder": "Np. nietolerancja laktozy, alergia na penicylinę…",
|
||||
"focusLabel": "Na co Coach ma zwracać uwagę?",
|
||||
"focusPlaceholder": "Np. pilnuj mojego ciśnienia, motywuj mnie do ruchu…",
|
||||
"completeness": "Wypełnione: {answered} z {total}",
|
||||
"questionsTitle": "Coach ma pytania",
|
||||
"questionsOpenCoach": "Odpowiedz w Coachu"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -4307,32 +4320,51 @@
|
||||
"invites": {
|
||||
"title": "Zaproszenia",
|
||||
"description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji. Link jest wyświetlany tylko raz.",
|
||||
"createLabel": "Nowe zaproszenie:",
|
||||
"adminOnlyHint": "Tylko administratorzy mogą tworzyć i unieważniać zaproszenia.",
|
||||
"create": "Utwórz zaproszenie",
|
||||
"createTitle": "Utwórz zaproszenie",
|
||||
"createDescription": "Link umożliwia rejestrację nawet przy zamkniętej rejestracji.",
|
||||
"ttlLegend": "Ważne przez",
|
||||
"createDays": "{days} dni",
|
||||
"maxUsesLabel": "Maksymalna liczba użyć",
|
||||
"maxUsesHint": "Ile rejestracji dopuszcza ten link (1–50).",
|
||||
"createConfirm": "Utwórz zaproszenie",
|
||||
"createdToast": "Zaproszenie utworzone.",
|
||||
"createError": "Nie udało się utworzyć zaproszenia.",
|
||||
"tokenTitle": "Link z zaproszeniem",
|
||||
"tokenOnceHint": "Wyświetlane tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.",
|
||||
"tokenOnceHint": "Do skopiowania tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.",
|
||||
"hashExplainer": "Zapisywany jest tylko hash linku — nie można go później ponownie wyświetlić. W razie utraty unieważnij zaproszenie i utwórz nowe.",
|
||||
"copyLink": "Kopiuj link",
|
||||
"copiedToast": "Link skopiowany.",
|
||||
"copyError": "Kopiowanie nie powiodło się.",
|
||||
"qrAlt": "Kod QR linku z zaproszeniem",
|
||||
"loadError": "Nie udało się wczytać zaproszeń.",
|
||||
"listEmpty": "Brak zaproszeń.",
|
||||
"expires": "wygasa {date}",
|
||||
"uses": "{used} / {max} wykorzystane",
|
||||
"createdMeta": "Utworzono {date} przez {by}",
|
||||
"usedBy": "ostatnio użyte przez {username}",
|
||||
"emptyTitle": "Brak zaproszeń",
|
||||
"emptyDescription": "Utwórz link z zaproszeniem, aby przyjąć kogoś mimo zamkniętej rejestracji.",
|
||||
"colStatus": "Status",
|
||||
"colCreated": "Utworzono",
|
||||
"colExpires": "Wygasa",
|
||||
"colUses": "Użycia",
|
||||
"colRedeemedBy": "Wykorzystane przez",
|
||||
"colActions": "Akcje",
|
||||
"byCreator": "przez {by}",
|
||||
"expiresInDaysOne": "za 1 dzień",
|
||||
"expiresInDaysOther": "za {count} dni",
|
||||
"statusActive": "Aktywne",
|
||||
"statusExpired": "Wygasłe",
|
||||
"statusExhausted": "Wyczerpane",
|
||||
"deleteAria": "Usuń zaproszenie",
|
||||
"deleteConfirmTitle": "Usunąć to zaproszenie?",
|
||||
"deleteConfirmBody": "Link z zaproszeniem natychmiast straci ważność. Konta już zarejestrowane przez ten link pozostaną.",
|
||||
"deleteConfirmAction": "Usuń",
|
||||
"statusRevoked": "Unieważnione",
|
||||
"redeemerDeleted": "usunięte konto",
|
||||
"redeemedByMore": "{username} + {count} więcej",
|
||||
"redemptionsTitle": "Wykorzystania: {count}",
|
||||
"revoke": "Unieważnij",
|
||||
"revokeAria": "Unieważnij zaproszenie",
|
||||
"revokeConfirmTitle": "Unieważnić to zaproszenie?",
|
||||
"revokeConfirmBody": "Link z zaproszeniem natychmiast straci ważność. Konta już zarejestrowane przez ten link pozostaną, a historia wykorzystań będzie nadal widoczna.",
|
||||
"revokeConfirmAction": "Unieważnij",
|
||||
"cancel": "Anuluj",
|
||||
"deletedToast": "Zaproszenie usunięte.",
|
||||
"deleteError": "Nie udało się usunąć zaproszenia."
|
||||
"revokedToast": "Zaproszenie unieważnione.",
|
||||
"revokeError": "Nie udało się unieważnić zaproszenia."
|
||||
},
|
||||
"userManagement": "Zarządzanie użytkownikami",
|
||||
"userName": "Nazwa",
|
||||
@@ -4552,6 +4584,10 @@
|
||||
"emptyDescription": "Brak użytkowników pasujących do bieżącego filtra.",
|
||||
"emptyResetFilter": "Pokaż wszystkich użytkowników"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Zaproszenia",
|
||||
"subtitle": "Tworzenie, śledzenie i unieważnianie linków z zaproszeniem."
|
||||
},
|
||||
"api-tokens": {
|
||||
"title": "Tokeny API",
|
||||
"subtitle": "Aktywne tokeny bearer wydane klientom natywnym i integracjom."
|
||||
@@ -5199,7 +5235,13 @@
|
||||
"bpTitle": "Podwyższone ciśnienie w tym tygodniu",
|
||||
"bpBody": "Twoje średnie ciśnienie w tym tygodniu przekroczyło cel. Coach pomoże Ci umieścić wyniki w kontekście.",
|
||||
"scoreTitle": "Twój wskaźnik regeneracji spada",
|
||||
"scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny."
|
||||
"scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny.",
|
||||
"selfContextTitle": "Pomóż Coachowi lepiej Cię poznać",
|
||||
"selfContextBody": "Twój opis o sobie jest niepełny lub dawno nie był aktualizowany. Dwie minuty w ustawieniach utrzymają kontekst Coacha w aktualnym stanie.",
|
||||
"questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?",
|
||||
"questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?",
|
||||
"questionAllergies": "Czy masz alergie lub nietolerancje?",
|
||||
"questionAboutMe": "Chcesz opowiedzieć Coachowi coś o sobie — codzienność, cele, kontekst?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Udostępniona dokumentacja zdrowotna",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- v1.16.0 — invite redemption ledger + soft revocation.
|
||||
--
|
||||
-- `invite_tokens.used_by` keeps only the LAST consumer (multi-use
|
||||
-- invites overwrite it); `invite_redemptions` carries one row per
|
||||
-- admitted signup so the admin table can show the full history with
|
||||
-- timestamps. `user_id` survives account deletion as NULL — the row
|
||||
-- still documents that a use was consumed.
|
||||
--
|
||||
-- `revoked_at` turns revocation into a soft state: a revoked invite
|
||||
-- keeps its history visible instead of vanishing, and the consume path
|
||||
-- refuses it like an expired one. Additive + non-destructive.
|
||||
ALTER TABLE "invite_tokens" ADD COLUMN "revoked_at" TIMESTAMP(3);
|
||||
|
||||
CREATE TABLE "invite_redemptions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"invite_id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"redeemed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "invite_redemptions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "invite_redemptions_invite_id_idx" ON "invite_redemptions"("invite_id");
|
||||
|
||||
ALTER TABLE "invite_redemptions"
|
||||
ADD CONSTRAINT "invite_redemptions_invite_id_fkey"
|
||||
FOREIGN KEY ("invite_id") REFERENCES "invite_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "invite_redemptions"
|
||||
ADD CONSTRAINT "invite_redemptions_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill: every invite that already recorded a consumer becomes one
|
||||
-- redemption row, stamped with the invite's `used_at`. Invites whose
|
||||
-- consumer account was deleted (`used_by` NULL but `uses` > 0) cannot
|
||||
-- be reconstructed and stay ledger-less — the use counter still counts.
|
||||
INSERT INTO "invite_redemptions" ("id", "invite_id", "user_id", "redeemed_at")
|
||||
SELECT
|
||||
'invred_' || md5(random()::text || clock_timestamp()::text),
|
||||
"id",
|
||||
"used_by",
|
||||
COALESCE("used_at", "created_at")
|
||||
FROM "invite_tokens"
|
||||
WHERE "used_by" IS NOT NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- v1.16.0 — structured self-context fields on the health profile.
|
||||
--
|
||||
-- Three short structured companions to the free-text "about me"
|
||||
-- (chronic conditions, allergies/intolerances, what the Coach should
|
||||
-- watch), each encrypted at rest through the shared Bytes codec and
|
||||
-- capped at 500 chars BEFORE encryption. Age/gender deliberately do
|
||||
-- NOT live here — they come from the users table and are merged into
|
||||
-- the prompt block at assembly time.
|
||||
--
|
||||
-- `pending_questions_encrypted` carries up to 3 clarifying questions
|
||||
-- the server derives after a save (encrypted JSON string[]); the Coach
|
||||
-- composer renders them as tappable suggestion chips. Additive +
|
||||
-- non-destructive.
|
||||
ALTER TABLE "user_health_profiles" ADD COLUMN "conditions_encrypted" BYTEA;
|
||||
ALTER TABLE "user_health_profiles" ADD COLUMN "allergies_encrypted" BYTEA;
|
||||
ALTER TABLE "user_health_profiles" ADD COLUMN "coach_focus_encrypted" BYTEA;
|
||||
ALTER TABLE "user_health_profiles" ADD COLUMN "pending_questions_encrypted" BYTEA;
|
||||
+40
-2
@@ -520,6 +520,8 @@ model User {
|
||||
// multi-use invite).
|
||||
invitesCreated InviteToken[] @relation("InviteCreator")
|
||||
invitesUsed InviteToken[] @relation("InviteConsumer")
|
||||
// v1.16.0 — per-signup invite redemption ledger (multi-use history).
|
||||
inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -1851,14 +1853,37 @@ model InviteToken {
|
||||
usedBy String? @map("used_by")
|
||||
maxUses Int @default(1) @map("max_uses")
|
||||
uses Int @default(0)
|
||||
// v1.16.0 — soft revocation. A revoked invite keeps its redemption
|
||||
// history visible in the admin table instead of vanishing; the
|
||||
// consume path refuses it like an expired one.
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
|
||||
creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade)
|
||||
consumer User? @relation("InviteConsumer", fields: [usedBy], references: [id], onDelete: SetNull)
|
||||
creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade)
|
||||
consumer User? @relation("InviteConsumer", fields: [usedBy], references: [id], onDelete: SetNull)
|
||||
redemptions InviteRedemption[]
|
||||
|
||||
@@index([expiresAt])
|
||||
@@map("invite_tokens")
|
||||
}
|
||||
|
||||
// v1.16.0 — one row per signup an invite admitted. `InviteToken.usedBy`
|
||||
// keeps only the LAST consumer (it overwrites on multi-use invites);
|
||||
// this ledger carries the full history so the admin table can show
|
||||
// every redemption with its timestamp. `userId` survives account
|
||||
// deletion as NULL — the row still documents that a use was consumed.
|
||||
model InviteRedemption {
|
||||
id String @id @default(cuid())
|
||||
inviteId String @map("invite_id")
|
||||
userId String? @map("user_id")
|
||||
redeemedAt DateTime @default(now()) @map("redeemed_at")
|
||||
|
||||
invite InviteToken @relation(fields: [inviteId], references: [id], onDelete: Cascade)
|
||||
user User? @relation("InviteRedemptions", fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([inviteId])
|
||||
@@map("invite_redemptions")
|
||||
}
|
||||
|
||||
// ─── Refresh Tokens (native client short-lived access + rotating refresh) ─
|
||||
|
||||
model RefreshToken {
|
||||
@@ -3080,6 +3105,19 @@ model UserHealthProfile {
|
||||
// Free text, hard-capped at 4 000 chars by the route's Zod schema
|
||||
// BEFORE encryption. NULL = cleared / never written.
|
||||
aboutMeEncrypted Bytes? @map("about_me_encrypted")
|
||||
// v1.16.0 — structured self-context companions to the free text.
|
||||
// Short answers (each capped at 500 chars BEFORE encryption).
|
||||
// Age/gender deliberately do NOT live here — they come from the User
|
||||
// profile and are merged into the prompt block at assembly time so
|
||||
// the data is never duplicated.
|
||||
conditionsEncrypted Bytes? @map("conditions_encrypted")
|
||||
allergiesEncrypted Bytes? @map("allergies_encrypted")
|
||||
coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
|
||||
// v1.16.0 — up to 3 clarifying questions the server derives after a
|
||||
// save (AI-generated when a provider + budget allow, deterministic
|
||||
// hints otherwise). Encrypted JSON string[]; the Coach composer
|
||||
// renders them as tappable suggestion chips. NULL = none pending.
|
||||
pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -139,12 +139,19 @@ export function AdminSectionRenderer({
|
||||
title={t("admin.section.users.title")}
|
||||
subtitle={t("admin.section.users.subtitle")}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<UserManagementSection />
|
||||
{/* v1.15.20 — registration invites live with the accounts
|
||||
they admit. */}
|
||||
<InviteTokensSection />
|
||||
</div>
|
||||
<UserManagementSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "invites":
|
||||
// v1.16.0 — invites moved out of Users into their own section:
|
||||
// the full table (status, redemptions, revocation) is a workflow
|
||||
// of its own and was drowning under the account list.
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.invites.title")}
|
||||
subtitle={t("admin.section.invites.subtitle")}
|
||||
>
|
||||
<InviteTokensSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "api-tokens":
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* DELETE /api/admin/invites/{id} — revoke a registration invite.
|
||||
*
|
||||
* v1.15.20 — hard delete: an invite row carries no health data and no
|
||||
* plaintext secret, and the audit log already records mint + revoke, so
|
||||
* keeping a tombstone would only grow the table. Idempotent — deleting
|
||||
* an unknown id returns `{ deleted: false }` rather than 404 so a
|
||||
* double-tap in the admin UI never surfaces an error.
|
||||
* v1.16.0 — soft revocation (`revokedAt`), replacing the v1.15.20 hard
|
||||
* delete: the admin table now renders redemption history per invite,
|
||||
* and a deleted row would erase who was admitted through it. The
|
||||
* consume path refuses a revoked invite like an expired one.
|
||||
*
|
||||
* Idempotent — revoking an unknown or already-revoked id returns
|
||||
* `{ revoked: false }` rather than 404 so a double-tap in the admin UI
|
||||
* never surfaces an error.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
@@ -20,11 +23,14 @@ export const DELETE = apiHandler(
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
const { count } = await prisma.inviteToken.deleteMany({ where: { id } });
|
||||
const deleted = count > 0;
|
||||
const { count } = await prisma.inviteToken.updateMany({
|
||||
where: { id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
const revoked = count > 0;
|
||||
|
||||
if (deleted) {
|
||||
await auditLog("admin.invite.deleted", {
|
||||
if (revoked) {
|
||||
await auditLog("admin.invite.revoked", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { inviteId: id },
|
||||
@@ -32,10 +38,10 @@ export const DELETE = apiHandler(
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.invite.deleted" },
|
||||
meta: { deleted },
|
||||
action: { name: "admin.invite.revoked" },
|
||||
meta: { revoked },
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted });
|
||||
return apiSuccess({ revoked });
|
||||
},
|
||||
);
|
||||
@@ -40,10 +40,21 @@ export const GET = apiHandler(async () => {
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
usedAt: true,
|
||||
revokedAt: true,
|
||||
uses: true,
|
||||
maxUses: true,
|
||||
creator: { select: { id: true, username: true } },
|
||||
consumer: { select: { id: true, username: true } },
|
||||
// v1.16.0 — full per-signup history. `consumer` only carries the
|
||||
// LAST account; multi-use invites list every admitted signup here.
|
||||
redemptions: {
|
||||
select: {
|
||||
id: true,
|
||||
redeemedAt: true,
|
||||
user: { select: { id: true, username: true, email: true } },
|
||||
},
|
||||
orderBy: { redeemedAt: "desc" },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* GET /api/coach/about-me/questions — pending clarifying questions.
|
||||
* DELETE /api/coach/about-me/questions — dismiss one (body
|
||||
* `{ question }`) or all (empty body / `{}`).
|
||||
*
|
||||
* v1.16.0 — the Coach composer renders the pending questions as
|
||||
* tappable suggestion chips. Tapping a chip inserts the question into
|
||||
* the composer AND dismisses it here; the small ✕ on a chip dismisses
|
||||
* without inserting. The questions are user-visible suggestion text
|
||||
* only — stored encrypted, scoped to the caller, never interpreted
|
||||
* server-side beyond exact-match removal.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getPendingQuestionsForUser,
|
||||
PENDING_QUESTION_MAX_CHARS,
|
||||
setPendingQuestionsForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
|
||||
const dismissSchema = z.object({
|
||||
/** Exact question text to dismiss. Omitted = dismiss all. */
|
||||
question: z.string().max(PENDING_QUESTION_MAX_CHARS).optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const questions = await getPendingQuestionsForUser(user.id);
|
||||
annotate({
|
||||
action: { name: "coach.about_me.questions.get" },
|
||||
meta: { count: questions.length },
|
||||
});
|
||||
return apiSuccess({ questions });
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = dismissSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const current = await getPendingQuestionsForUser(user.id);
|
||||
const remaining =
|
||||
parsed.data.question === undefined
|
||||
? []
|
||||
: current.filter((q) => q !== parsed.data.question);
|
||||
await setPendingQuestionsForUser(user.id, remaining);
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.about_me.questions.dismissed" },
|
||||
meta: { before: current.length, after: remaining.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ questions: remaining });
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -1,21 +1,28 @@
|
||||
/**
|
||||
* GET /api/coach/about-me — the caller's "about me" self-description.
|
||||
* PUT /api/coach/about-me — write (or clear) the self-description.
|
||||
* GET /api/coach/about-me — the caller's structured self-context.
|
||||
* PUT /api/coach/about-me — write (or clear) the self-context.
|
||||
*
|
||||
* v1.15.20 — the user-authored context the Coach system prompt and the
|
||||
* daily briefing inject as a delimited, user-provided block (see
|
||||
* `src/lib/ai/coach/about-me.ts`). Free text, hard-capped at 4 000 chars,
|
||||
* encrypted at rest through the shared Bytes codec. An empty / whitespace
|
||||
* PUT clears the text (the row is kept with a NULL payload so `updatedAt`
|
||||
* documents the deletion instant).
|
||||
* v1.15.20 — free-text "about me", encrypted at rest.
|
||||
* v1.16.0 — extended with three structured fields (conditions,
|
||||
* allergies, coach focus; each ≤500 chars, encrypted) and the
|
||||
* clarifying-questions loop: after a save the server derives up to 3
|
||||
* follow-up questions (AI when a provider + the daily Coach budget
|
||||
* allow, deterministic hints otherwise) and persists them encrypted;
|
||||
* the Coach composer renders them as tappable chips
|
||||
* (`/api/coach/about-me/questions`).
|
||||
*
|
||||
* Plain text end to end: the client renders the value as a React text
|
||||
* child only — no markdown library exists in the tree and none may be
|
||||
* added (XSS posture, see the contributor notes).
|
||||
* PUT field semantics: `aboutMe` is required (empty string clears).
|
||||
* The structured fields are optional — omitted leaves the stored value
|
||||
* untouched, an empty string clears it — so older clients that only
|
||||
* send `aboutMe` keep working unchanged.
|
||||
*
|
||||
* Plain text end to end: the client renders every value as a React
|
||||
* text child only — no markdown library exists in the tree and none
|
||||
* may be added (XSS posture, see the contributor notes).
|
||||
*
|
||||
* Ownership: the user id always comes from `requireAuth()`; the body
|
||||
* carries only the text. Audit rows never contain the text itself — only
|
||||
* its length — because it is free-form health prose.
|
||||
* carries only the text. Audit rows never contain the text itself —
|
||||
* only per-field lengths — because it is free-form health prose.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
@@ -29,11 +36,15 @@ 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 { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
decryptFromBytes,
|
||||
encryptToBytes,
|
||||
} from "@/lib/ai/coach/bytes-codec";
|
||||
getPendingQuestionsForUser,
|
||||
getSelfContextForUser,
|
||||
setPendingQuestionsForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
|
||||
import {
|
||||
ABOUT_ME_FIELD_MAX_CHARS,
|
||||
ABOUT_ME_MAX_CHARS,
|
||||
aboutMePutSchema,
|
||||
} from "@/lib/validations/about-me";
|
||||
@@ -44,32 +55,35 @@ const PUT_WINDOW_MS = 60_000;
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const row = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { aboutMeEncrypted: true, updatedAt: true },
|
||||
});
|
||||
|
||||
let aboutMe: string | null = null;
|
||||
if (row?.aboutMeEncrypted) {
|
||||
try {
|
||||
aboutMe = decryptFromBytes(row.aboutMeEncrypted);
|
||||
} catch {
|
||||
// Fail closed per row — never surface ciphertext. The user sees an
|
||||
// empty editor and can re-write; the stale ciphertext is replaced
|
||||
// on the next save.
|
||||
aboutMe = null;
|
||||
}
|
||||
}
|
||||
const [ctx, pendingQuestions, row] = await Promise.all([
|
||||
getSelfContextForUser(user.id),
|
||||
getPendingQuestionsForUser(user.id),
|
||||
prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.about_me.get" },
|
||||
meta: { present: aboutMe !== null },
|
||||
meta: {
|
||||
present: ctx.aboutMe !== null,
|
||||
structured:
|
||||
ctx.conditions !== null ||
|
||||
ctx.allergies !== null ||
|
||||
ctx.coachFocus !== null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
aboutMe,
|
||||
aboutMe: ctx.aboutMe,
|
||||
conditions: ctx.conditions,
|
||||
allergies: ctx.allergies,
|
||||
coachFocus: ctx.coachFocus,
|
||||
pendingQuestions,
|
||||
updatedAt: row?.updatedAt?.toISOString() ?? null,
|
||||
maxChars: ABOUT_ME_MAX_CHARS,
|
||||
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,42 +115,109 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
|
||||
const text = parsed.data.aboutMe.trim();
|
||||
const cleared = text.length === 0;
|
||||
const payload = cleared ? null : encryptToBytes(text);
|
||||
|
||||
// Field-by-field data assembly (no mass assignment): omitted fields
|
||||
// never appear in the update object, so they stay untouched.
|
||||
const encryptOptional = (
|
||||
raw: string | undefined,
|
||||
): Uint8Array<ArrayBuffer> | null | undefined => {
|
||||
if (raw === undefined) return undefined;
|
||||
const value = raw.trim();
|
||||
return value.length === 0 ? null : encryptToBytes(value);
|
||||
};
|
||||
const update: {
|
||||
aboutMeEncrypted: Uint8Array<ArrayBuffer> | null;
|
||||
conditionsEncrypted?: Uint8Array<ArrayBuffer> | null;
|
||||
allergiesEncrypted?: Uint8Array<ArrayBuffer> | null;
|
||||
coachFocusEncrypted?: Uint8Array<ArrayBuffer> | null;
|
||||
} = {
|
||||
aboutMeEncrypted: cleared ? null : encryptToBytes(text),
|
||||
};
|
||||
const conditionsPayload = encryptOptional(parsed.data.conditions);
|
||||
if (conditionsPayload !== undefined) {
|
||||
update.conditionsEncrypted = conditionsPayload;
|
||||
}
|
||||
const allergiesPayload = encryptOptional(parsed.data.allergies);
|
||||
if (allergiesPayload !== undefined) {
|
||||
update.allergiesEncrypted = allergiesPayload;
|
||||
}
|
||||
const coachFocusPayload = encryptOptional(parsed.data.coachFocus);
|
||||
if (coachFocusPayload !== undefined) {
|
||||
update.coachFocusEncrypted = coachFocusPayload;
|
||||
}
|
||||
const fieldLengths: Record<string, number> = {
|
||||
aboutMe: text.length,
|
||||
...(parsed.data.conditions !== undefined
|
||||
? { conditions: parsed.data.conditions.trim().length }
|
||||
: {}),
|
||||
...(parsed.data.allergies !== undefined
|
||||
? { allergies: parsed.data.allergies.trim().length }
|
||||
: {}),
|
||||
...(parsed.data.coachFocus !== undefined
|
||||
? { coachFocus: parsed.data.coachFocus.trim().length }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const row = await prisma.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: {
|
||||
userId: user.id,
|
||||
aboutMeEncrypted: payload,
|
||||
},
|
||||
update: {
|
||||
aboutMeEncrypted: payload,
|
||||
},
|
||||
create: { userId: user.id, ...update },
|
||||
update,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
|
||||
// The audit row carries the length only — the text is free-form health
|
||||
// prose and must not land in the (plaintext) audit details.
|
||||
// Read back the effective state (covers omitted fields) and derive
|
||||
// the clarifying questions. An entirely empty self-context clears
|
||||
// the pending questions instead of generating new ones.
|
||||
const ctx = await getSelfContextForUser(user.id);
|
||||
const isEmpty =
|
||||
ctx.aboutMe === null &&
|
||||
ctx.conditions === null &&
|
||||
ctx.allergies === null &&
|
||||
ctx.coachFocus === null;
|
||||
|
||||
let pendingQuestions: string[] = [];
|
||||
let questionsSource: "ai" | "fallback" | "none" = "none";
|
||||
if (isEmpty) {
|
||||
await setPendingQuestionsForUser(user.id, null);
|
||||
} else {
|
||||
const derived = await deriveClarifyingQuestions(user.id, ctx, user.locale);
|
||||
pendingQuestions = derived.questions;
|
||||
questionsSource = derived.source;
|
||||
await setPendingQuestionsForUser(user.id, pendingQuestions);
|
||||
}
|
||||
|
||||
// The audit row carries per-field lengths only — the text is
|
||||
// free-form health prose and must not land in the plaintext audit
|
||||
// details.
|
||||
await auditLog(
|
||||
cleared ? "coach.about_me.cleared" : "coach.about_me.updated",
|
||||
isEmpty ? "coach.about_me.cleared" : "coach.about_me.updated",
|
||||
{
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { length: text.length },
|
||||
details: { ...fieldLengths },
|
||||
},
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: cleared ? "coach.about_me.cleared" : "coach.about_me.updated",
|
||||
name: isEmpty ? "coach.about_me.cleared" : "coach.about_me.updated",
|
||||
},
|
||||
meta: {
|
||||
length: text.length,
|
||||
questions_source: questionsSource,
|
||||
questions_count: pendingQuestions.length,
|
||||
},
|
||||
meta: { length: text.length },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
aboutMe: cleared ? null : text,
|
||||
aboutMe: ctx.aboutMe,
|
||||
conditions: ctx.conditions,
|
||||
allergies: ctx.allergies,
|
||||
coachFocus: ctx.coachFocus,
|
||||
pendingQuestions,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
maxChars: ABOUT_ME_MAX_CHARS,
|
||||
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { detectRefusal } from "@/lib/ai/coach/refusal";
|
||||
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
|
||||
import { getAboutMeForUser } from "@/lib/ai/coach/about-me";
|
||||
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
|
||||
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
@@ -318,7 +318,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// AI) rides the system prompt as a delimited, user-provided context
|
||||
// block. Fail-open: a missing / undecryptable text yields null and the
|
||||
// prompt is byte-identical to the pre-feature one.
|
||||
const aboutMe = await getAboutMeForUser(userId);
|
||||
// v1.16.0 — composed self-context: structured questionnaire fields
|
||||
// plus age/gender merged in from the User profile.
|
||||
const aboutMe = await getSelfContextTextForUser(userId, locale);
|
||||
const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe);
|
||||
const allTurns: CoachTurn[] = [
|
||||
...priorTurns,
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
|
||||
import {
|
||||
buildAboutMeInsightBlock,
|
||||
getAboutMeForUser,
|
||||
getSelfContextTextForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
@@ -551,7 +551,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// v1.15.20 — fold the user-authored "about me" self-description
|
||||
// (Settings → AI) into the briefing as a delimited, user-provided
|
||||
// SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing.
|
||||
const aboutMe = await getAboutMeForUser(userId);
|
||||
const aboutMe = await getSelfContextTextForUser(userId, locale);
|
||||
if (aboutMe) {
|
||||
userPrompt += buildAboutMeInsightBlock(aboutMe, locale);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* v1.16.0 — admin invite table: status-derivation precedence.
|
||||
*
|
||||
* revoked > exhausted > expired > active. Revocation must win even on
|
||||
* a row that is also expired/exhausted, because the badge documents
|
||||
* the admin's action; exhaustion beats expiry because "it was fully
|
||||
* used" is the more informative fact.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { deriveInviteStatus } from "../invite-tokens-section";
|
||||
|
||||
const NOW = new Date("2026-06-10T12:00:00Z");
|
||||
const FUTURE = new Date(NOW.getTime() + 86_400_000).toISOString();
|
||||
const PAST = new Date(NOW.getTime() - 86_400_000).toISOString();
|
||||
|
||||
describe("deriveInviteStatus", () => {
|
||||
it("revoked wins over every other state", () => {
|
||||
expect(
|
||||
deriveInviteStatus(
|
||||
{ uses: 5, maxUses: 1, expiresAt: PAST, revokedAt: PAST },
|
||||
NOW,
|
||||
),
|
||||
).toBe("revoked");
|
||||
});
|
||||
|
||||
it("exhausted beats expired", () => {
|
||||
expect(
|
||||
deriveInviteStatus(
|
||||
{ uses: 1, maxUses: 1, expiresAt: PAST, revokedAt: null },
|
||||
NOW,
|
||||
),
|
||||
).toBe("exhausted");
|
||||
});
|
||||
|
||||
it("expired when past expiry with uses left", () => {
|
||||
expect(
|
||||
deriveInviteStatus(
|
||||
{ uses: 0, maxUses: 1, expiresAt: PAST, revokedAt: null },
|
||||
NOW,
|
||||
),
|
||||
).toBe("expired");
|
||||
});
|
||||
|
||||
it("active otherwise", () => {
|
||||
expect(
|
||||
deriveInviteStatus(
|
||||
{ uses: 0, maxUses: 1, expiresAt: FUTURE, revokedAt: null },
|
||||
NOW,
|
||||
),
|
||||
).toBe("active");
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
ShieldAlert,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Ticket,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@@ -104,6 +105,13 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
titleKey: "admin.section.users.title",
|
||||
icon: Users,
|
||||
},
|
||||
// v1.16.0 — invites as their own section, directly after Users
|
||||
// (admission lives next to the accounts it creates).
|
||||
{
|
||||
slug: "invites",
|
||||
titleKey: "admin.section.invites.title",
|
||||
icon: Ticket,
|
||||
},
|
||||
{
|
||||
slug: "api-tokens",
|
||||
titleKey: "admin.section.api-tokens.title",
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.15.20 — admin card for registration invites (Users section).
|
||||
* v1.16.0 — Admin → Invites. Full management surface for registration
|
||||
* invite links (formerly a card inside the Users section).
|
||||
*
|
||||
* Mint an invite (7 / 14 / 30-day lifetime), hand it over as a link or
|
||||
* QR code, see the open invites, revoke one. The raw token + URL + QR
|
||||
* appear exactly once — straight from the POST response — and are gone
|
||||
* after a reload; the list endpoint only ever returns metadata.
|
||||
* Layout:
|
||||
* - Header row: explanatory copy + the single "create" action. TTL +
|
||||
* max-uses selection moved into the create dialog so the section
|
||||
* header stays calm (no 7/14/30 button wall).
|
||||
* - The freshly minted link + QR render INSIDE the dialog as a
|
||||
* one-time "ticket": only the HMAC hash is persisted, so the URL is
|
||||
* copyable exactly now and never again. The UI says so honestly.
|
||||
* - Desktop (md+): a real table — status, created, expiry
|
||||
* (relative + absolute), uses, redemption history, revoke.
|
||||
* - Mobile: stacked cards carrying the same facts.
|
||||
*
|
||||
* Status model (derived client-side from the row):
|
||||
* revoked > exhausted > expired > active. Revocation is soft
|
||||
* (DELETE sets `revokedAt`), so history rows never disappear.
|
||||
*
|
||||
* The QR data-URL is rendered inside the mutation (not in an effect):
|
||||
* `qrcode`'s `toDataURL` is async, so folding it into `mutationFn`
|
||||
@@ -14,7 +25,16 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, Loader2, Ticket, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
Copy,
|
||||
Loader2,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Ticket,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import QRCode from "qrcode";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -28,21 +48,53 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InviteRedemptionEntry {
|
||||
id: string;
|
||||
redeemedAt: string;
|
||||
user: { id: string; username: string; email: string | null } | null;
|
||||
}
|
||||
|
||||
interface AdminInvite {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
usedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
uses: number;
|
||||
maxUses: number;
|
||||
creator: { id: string; username: string } | null;
|
||||
consumer: { id: string; username: string } | null;
|
||||
redemptions: InviteRedemptionEntry[];
|
||||
}
|
||||
|
||||
interface MintedInvite {
|
||||
@@ -53,21 +105,79 @@ interface MintedInvite {
|
||||
qrDataUrl: string;
|
||||
}
|
||||
|
||||
type InviteStatus = "active" | "expired" | "exhausted";
|
||||
type InviteStatus = "active" | "expired" | "exhausted" | "revoked";
|
||||
|
||||
function deriveStatus(invite: AdminInvite, now: Date): InviteStatus {
|
||||
export function deriveInviteStatus(
|
||||
invite: Pick<
|
||||
AdminInvite,
|
||||
"uses" | "maxUses" | "expiresAt" | "revokedAt"
|
||||
>,
|
||||
now: Date,
|
||||
): InviteStatus {
|
||||
if (invite.revokedAt !== null) return "revoked";
|
||||
if (invite.uses >= invite.maxUses) return "exhausted";
|
||||
if (new Date(invite.expiresAt).getTime() <= now.getTime()) return "expired";
|
||||
return "active";
|
||||
}
|
||||
|
||||
const TTL_CHOICES = [7, 14, 30] as const;
|
||||
const MAX_USES_CAP = 50;
|
||||
|
||||
/** Whole days (ceil) from `now` until `iso`; negative when past. */
|
||||
function daysUntil(iso: string, now: Date): number {
|
||||
const ms = new Date(iso).getTime() - now.getTime();
|
||||
return Math.ceil(ms / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<InviteStatus, { dot: string; chip: string }> = {
|
||||
// Quiet tinted chips with a colour dot — the dot carries the state at
|
||||
// a glance, the tint stays low so a table of mixed states reads calm.
|
||||
active: {
|
||||
dot: "bg-dracula-green",
|
||||
chip: "border-dracula-green/30 bg-dracula-green/10 text-dracula-green",
|
||||
},
|
||||
expired: {
|
||||
dot: "bg-muted-foreground/60",
|
||||
chip: "border-border bg-muted/40 text-muted-foreground",
|
||||
},
|
||||
exhausted: {
|
||||
dot: "bg-dracula-cyan",
|
||||
chip: "border-dracula-cyan/30 bg-dracula-cyan/10 text-dracula-cyan",
|
||||
},
|
||||
revoked: {
|
||||
dot: "bg-dracula-red",
|
||||
chip: "border-dracula-red/30 bg-dracula-red/10 text-dracula-red",
|
||||
},
|
||||
};
|
||||
|
||||
function StatusChip({ status, label }: { status: InviteStatus; label: string }) {
|
||||
const styles = STATUS_STYLES[status];
|
||||
return (
|
||||
<span
|
||||
data-testid={`invite-status-${status}`}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium whitespace-nowrap",
|
||||
styles.chip,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5 rounded-full", styles.dot)}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteTokensSection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [ttlDays, setTtlDays] = useState<number>(7);
|
||||
const [maxUses, setMaxUses] = useState<string>("1");
|
||||
const [minted, setMinted] = useState<MintedInvite | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminInvite | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [revokeTarget, setRevokeTarget] = useState<AdminInvite | null>(null);
|
||||
|
||||
const { data: invites, isError } = useQuery({
|
||||
queryKey: queryKeys.adminInvites(),
|
||||
@@ -80,11 +190,14 @@ export function InviteTokensSection() {
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.adminInvites(),
|
||||
mutationFn: async (expiresInDays: number): Promise<MintedInvite> => {
|
||||
mutationFn: async (input: {
|
||||
expiresInDays: number;
|
||||
maxUses: number;
|
||||
}): Promise<MintedInvite> => {
|
||||
const res = await fetch("/api/admin/invites", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expiresInDays }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()).data as Omit<MintedInvite, "qrDataUrl">;
|
||||
@@ -104,206 +217,544 @@ export function InviteTokensSection() {
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
const revoke = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/admin/invites/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
toast.success(t("admin.invites.deletedToast"));
|
||||
if (minted?.id === id) setMinted(null);
|
||||
onSuccess: () => {
|
||||
toast.success(t("admin.invites.revokedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("admin.invites.deleteError"));
|
||||
toast.error(t("admin.invites.revokeError"));
|
||||
},
|
||||
});
|
||||
|
||||
async function copyUrl(url: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
toast.success(t("admin.invites.copiedToast"));
|
||||
window.setTimeout(() => setCopied(false), 2_000);
|
||||
} catch {
|
||||
toast.error(t("admin.invites.copyError"));
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setMinted(null);
|
||||
setCopied(false);
|
||||
setTtlDays(7);
|
||||
setMaxUses("1");
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
const parsedMax = Number.parseInt(maxUses, 10);
|
||||
const safeMax =
|
||||
Number.isFinite(parsedMax) && parsedMax >= 1
|
||||
? Math.min(parsedMax, MAX_USES_CAP)
|
||||
: 1;
|
||||
create.mutate({ expiresInDays: ttlDays, maxUses: safeMax });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
function statusLabel(status: InviteStatus): string {
|
||||
// Literal keys so the i18n call-site coverage guard sees them.
|
||||
switch (status) {
|
||||
case "active":
|
||||
return t("admin.invites.statusActive");
|
||||
case "expired":
|
||||
return t("admin.invites.statusExpired");
|
||||
case "exhausted":
|
||||
return t("admin.invites.statusExhausted");
|
||||
case "revoked":
|
||||
return t("admin.invites.statusRevoked");
|
||||
}
|
||||
}
|
||||
|
||||
function expiryRelative(invite: AdminInvite, status: InviteStatus): string {
|
||||
if (status !== "active") return "—";
|
||||
const days = daysUntil(invite.expiresAt, now);
|
||||
if (days <= 0) return "—";
|
||||
return days === 1
|
||||
? t("admin.invites.expiresInDaysOne")
|
||||
: t("admin.invites.expiresInDaysOther", { count: days });
|
||||
}
|
||||
|
||||
function redemptionSummary(invite: AdminInvite): string {
|
||||
if (invite.redemptions.length === 0) return "—";
|
||||
const first = invite.redemptions[0];
|
||||
const name =
|
||||
first.user?.username ?? t("admin.invites.redeemerDeleted");
|
||||
if (invite.redemptions.length === 1) return name;
|
||||
return t("admin.invites.redeemedByMore", {
|
||||
username: name,
|
||||
count: invite.redemptions.length - 1,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
<section
|
||||
aria-labelledby="admin-invites-title"
|
||||
className="bg-card border-border rounded-xl border"
|
||||
data-testid="admin-invites-card"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ticket className="text-primary h-5 w-5" aria-hidden="true" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.invites.title")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t("admin.invites.description")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("admin.invites.createLabel")}
|
||||
</span>
|
||||
{TTL_CHOICES.map((days) => (
|
||||
<Button
|
||||
key={days}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={create.isPending}
|
||||
onClick={() => create.mutate(days)}
|
||||
data-testid={`admin-invites-create-${days}`}
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{t("admin.invites.createDays", { days })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{minted && (
|
||||
<div
|
||||
className="border-border bg-background mt-4 space-y-3 rounded-lg border p-4"
|
||||
data-testid="admin-invites-minted"
|
||||
>
|
||||
<p className="text-sm font-medium">{t("admin.invites.tokenTitle")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.tokenOnceHint")}
|
||||
</p>
|
||||
<p className="bg-muted overflow-x-auto rounded-md p-2 font-mono text-xs break-all">
|
||||
{minted.url}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- data-URL QR, no remote loader involved */}
|
||||
<img
|
||||
src={minted.qrDataUrl}
|
||||
alt={t("admin.invites.qrAlt")}
|
||||
width={160}
|
||||
height={160}
|
||||
className="rounded-md bg-white p-2"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyUrl(minted.url)}
|
||||
>
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
{t("admin.invites.copyLink")}
|
||||
</Button>
|
||||
{/* Header — copy left, the one action right. */}
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ticket className="text-primary h-5 w-5" aria-hidden="true" />
|
||||
<h2 id="admin-invites-title" className="text-lg font-semibold">
|
||||
{t("admin.invites.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t("admin.invites.description")}
|
||||
</p>
|
||||
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 text-xs">
|
||||
<ShieldCheck className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("admin.invites.adminOnlyHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={openCreateDialog}
|
||||
data-testid="admin-invites-open-create"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
{t("admin.invites.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-destructive px-4 pb-4 text-sm sm:px-6">
|
||||
{t("admin.invites.loadError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{invites && invites.length === 0 && (
|
||||
<div className="px-4 pb-4 sm:px-6 sm:pb-6">
|
||||
<EmptyState
|
||||
icon={<Ticket className="size-6" aria-hidden="true" />}
|
||||
title={t("admin.invites.emptyTitle")}
|
||||
description={t("admin.invites.emptyDescription")}
|
||||
action={
|
||||
<Button type="button" variant="outline" onClick={openCreateDialog}>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
{t("admin.invites.create")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-2">
|
||||
{isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("admin.invites.loadError")}
|
||||
</p>
|
||||
)}
|
||||
{invites && invites.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.invites.listEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{invites?.map((invite) => {
|
||||
const status = deriveStatus(invite, now);
|
||||
// Literal keys so the i18n call-site coverage guard sees them.
|
||||
const statusLabel =
|
||||
status === "active"
|
||||
? t("admin.invites.statusActive")
|
||||
: status === "expired"
|
||||
? t("admin.invites.statusExpired")
|
||||
: t("admin.invites.statusExhausted");
|
||||
return (
|
||||
<div
|
||||
key={invite.id}
|
||||
className="border-border flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge
|
||||
variant={status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.expires", {
|
||||
date: formatDate(invite.expiresAt),
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{t("admin.invites.uses", {
|
||||
used: invite.uses,
|
||||
max: invite.maxUses,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.createdMeta", {
|
||||
date: formatDate(invite.createdAt),
|
||||
by: invite.creator?.username ?? "—",
|
||||
})}
|
||||
{invite.consumer
|
||||
? ` · ${t("admin.invites.usedBy", {
|
||||
username: invite.consumer.username,
|
||||
})}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
aria-label={t("admin.invites.deleteAria")}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => setDeleteTarget(invite)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{invites && invites.length > 0 && (
|
||||
<>
|
||||
{/* Desktop table (md+) */}
|
||||
<div className="hidden px-2 pb-2 md:block">
|
||||
<Table data-testid="admin-invites-table">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>{t("admin.invites.colStatus")}</TableHead>
|
||||
<TableHead>{t("admin.invites.colCreated")}</TableHead>
|
||||
<TableHead>{t("admin.invites.colExpires")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("admin.invites.colUses")}
|
||||
</TableHead>
|
||||
<TableHead>{t("admin.invites.colRedeemedBy")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<span className="sr-only">
|
||||
{t("admin.invites.colActions")}
|
||||
</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invites.map((invite) => {
|
||||
const status = deriveInviteStatus(invite, now);
|
||||
const relative = expiryRelative(invite, status);
|
||||
return (
|
||||
<TableRow
|
||||
key={invite.id}
|
||||
data-testid="admin-invite-row"
|
||||
className={cn(
|
||||
status !== "active" && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<TableCell>
|
||||
<StatusChip
|
||||
status={status}
|
||||
label={statusLabel(status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm tabular-nums">
|
||||
{formatDate(invite.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.byCreator", {
|
||||
by: invite.creator?.username ?? "—",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm tabular-nums">
|
||||
{relative !== "—"
|
||||
? relative
|
||||
: formatDate(invite.expiresAt)}
|
||||
</div>
|
||||
{relative !== "—" && (
|
||||
<div className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatDate(invite.expiresAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
{invite.uses}
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
/ {invite.maxUses}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RedemptionsCell
|
||||
invite={invite}
|
||||
summary={redemptionSummary(invite)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{status === "active" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-dracula-red hover:text-dracula-red"
|
||||
aria-label={t("admin.invites.revokeAria")}
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => setRevokeTarget(invite)}
|
||||
data-testid="admin-invite-revoke"
|
||||
>
|
||||
<Ban className="size-4" aria-hidden="true" />
|
||||
{t("admin.invites.revoke")}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
{/* Mobile cards (<md) */}
|
||||
<ul className="space-y-2 px-4 pb-4 md:hidden">
|
||||
{invites.map((invite) => {
|
||||
const status = deriveInviteStatus(invite, now);
|
||||
const relative = expiryRelative(invite, status);
|
||||
return (
|
||||
<li
|
||||
key={invite.id}
|
||||
className="border-border rounded-lg border p-3"
|
||||
data-testid="admin-invite-row"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<StatusChip status={status} label={statusLabel(status)} />
|
||||
<span className="text-sm tabular-nums">
|
||||
{invite.uses}
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
/ {invite.maxUses}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<dl className="text-muted-foreground mt-2 space-y-1 text-xs">
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>{t("admin.invites.colCreated")}</dt>
|
||||
<dd className="text-right tabular-nums">
|
||||
{formatDate(invite.createdAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>{t("admin.invites.colExpires")}</dt>
|
||||
<dd className="text-right tabular-nums">
|
||||
{relative !== "—" ? `${relative} · ` : ""}
|
||||
{formatDate(invite.expiresAt)}
|
||||
</dd>
|
||||
</div>
|
||||
{invite.redemptions.length > 0 && (
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>{t("admin.invites.colRedeemedBy")}</dt>
|
||||
<dd className="text-foreground text-right">
|
||||
{redemptionSummary(invite)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{status === "active" && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-dracula-red hover:text-dracula-red"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => setRevokeTarget(invite)}
|
||||
>
|
||||
<Ban className="size-4" aria-hidden="true" />
|
||||
{t("admin.invites.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create dialog — selection first, then the one-time ticket. */}
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
setCreateOpen(open);
|
||||
if (!open) setMinted(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{minted === null ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("admin.invites.createTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("admin.invites.createDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">
|
||||
{t("admin.invites.ttlLegend")}
|
||||
</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2" role="radiogroup">
|
||||
{TTL_CHOICES.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={ttlDays === days}
|
||||
onClick={() => setTtlDays(days)}
|
||||
data-testid={`admin-invites-ttl-${days}`}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
ttlDays === days
|
||||
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{t("admin.invites.createDays", { days })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-invites-max-uses">
|
||||
{t("admin.invites.maxUsesLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-invites-max-uses"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_USES_CAP}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
className="w-24 tabular-nums"
|
||||
data-testid="admin-invites-max-uses"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.maxUsesHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={create.isPending}
|
||||
onClick={submitCreate}
|
||||
data-testid="admin-invites-submit-create"
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Ticket className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("admin.invites.createConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("admin.invites.tokenTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("admin.invites.tokenOnceHint")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* The ticket — dashed edge marks the one-time nature. */}
|
||||
<div
|
||||
className="border-dracula-purple/40 bg-background rounded-xl border border-dashed p-4"
|
||||
data-testid="admin-invites-minted"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- data-URL QR, no remote loader involved */}
|
||||
<img
|
||||
src={minted.qrDataUrl}
|
||||
alt={t("admin.invites.qrAlt")}
|
||||
width={176}
|
||||
height={176}
|
||||
className="rounded-lg bg-white p-2"
|
||||
/>
|
||||
<p className="bg-muted w-full overflow-x-auto rounded-md p-2 text-center font-mono text-xs break-all select-all">
|
||||
{minted.url}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => copyUrl(minted.url)}
|
||||
data-testid="admin-invites-copy"
|
||||
>
|
||||
{copied ? (
|
||||
<Check
|
||||
className="text-dracula-green size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Copy className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("admin.invites.copyLink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.hashExplainer")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Revoke confirmation */}
|
||||
<AlertDialog
|
||||
open={revokeTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRevokeTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.invites.deleteConfirmTitle")}
|
||||
{t("admin.invites.revokeConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.invites.deleteConfirmBody")}
|
||||
{t("admin.invites.revokeConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("admin.invites.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (deleteTarget) remove.mutate(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
if (revokeTarget) revoke.mutate(revokeTarget.id);
|
||||
setRevokeTarget(null);
|
||||
}}
|
||||
>
|
||||
{t("admin.invites.deleteConfirmAction")}
|
||||
{t("admin.invites.revokeConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Redeemed by" cell: single redeemer renders inline; multiple open a
|
||||
* popover listing every redemption with username, email, and timestamp
|
||||
* (the `InviteRedemption` ledger — `usedBy` alone only knows the last).
|
||||
*/
|
||||
function RedemptionsCell({
|
||||
invite,
|
||||
summary,
|
||||
}: {
|
||||
invite: AdminInvite;
|
||||
summary: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (invite.redemptions.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
if (invite.redemptions.length === 1) {
|
||||
const entry = invite.redemptions[0];
|
||||
return (
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm">
|
||||
{entry.user?.username ?? t("admin.invites.redeemerDeleted")}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatDateTime(entry.redeemedAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground inline-flex min-h-11 items-center gap-1.5 text-sm underline-offset-4 hover:underline"
|
||||
data-testid="admin-invite-redemptions-trigger"
|
||||
>
|
||||
<Users className="size-3.5" aria-hidden="true" />
|
||||
{summary}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72">
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.invites.redemptionsTitle", {
|
||||
count: invite.redemptions.length,
|
||||
})}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{invite.redemptions.map((entry) => (
|
||||
<li key={entry.id} className="text-sm leading-tight">
|
||||
<div>
|
||||
{entry.user?.username ?? t("admin.invites.redeemerDeleted")}
|
||||
{entry.user?.email && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· {entry.user.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatDateTime(entry.redeemedAt)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,11 @@ export const ADMIN_SECTION_SLUGS = [
|
||||
"feedback",
|
||||
"reminders",
|
||||
"users",
|
||||
// v1.16.0 — registration invites get their own section. They started
|
||||
// as a card inside Users, but the invite table (status, redemptions,
|
||||
// revocation) is a workflow of its own and was drowning under the
|
||||
// account list; "who may join" sits right next to "who is here".
|
||||
"invites",
|
||||
"api-tokens",
|
||||
"login-overview",
|
||||
"app-logs",
|
||||
|
||||
@@ -1,37 +1,54 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.15.20 — Settings → AI "About me" panel.
|
||||
* v1.15.20 — Settings → AI "About me" panel (free text only).
|
||||
* v1.16.0 — structured self-context: alongside the free text the user
|
||||
* answers three short questions (chronic conditions, allergies /
|
||||
* intolerances, what the Coach should watch). All four fields are
|
||||
* encrypted at rest; age/gender stay on the profile and are merged
|
||||
* into the prompt server-side — the panel says so instead of asking
|
||||
* again.
|
||||
*
|
||||
* Free-text self-description the user writes for the AI surfaces. The
|
||||
* server stores it encrypted (`UserHealthProfile.aboutMeEncrypted`) and
|
||||
* injects it into the Coach system prompt + the daily briefing as a
|
||||
* delimited, user-provided context block.
|
||||
* GET /api/coach/about-me — full structured payload + pending questions
|
||||
* PUT /api/coach/about-me — writes all four fields (empty string clears)
|
||||
*
|
||||
* GET /api/coach/about-me — { aboutMe, updatedAt, maxChars }
|
||||
* PUT /api/coach/about-me — { aboutMe } (empty string clears)
|
||||
* After a save the server derives up to 3 clarifying questions (AI
|
||||
* when a provider + budget allow, deterministic hints otherwise); the
|
||||
* panel surfaces them in a quiet callout and the Coach composer
|
||||
* renders them as tappable chips.
|
||||
*
|
||||
* Plain text only — the value is rendered exclusively through a
|
||||
* `<textarea>` and React text children; no markdown anywhere.
|
||||
* Plain text only — every value renders exclusively through inputs and
|
||||
* React text children; no markdown anywhere.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, NotebookPen, Trash2 } from "lucide-react";
|
||||
import { Loader2, MessageCircleQuestion, NotebookPen, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const FALLBACK_MAX_CHARS = 4000;
|
||||
const FALLBACK_FIELD_MAX_CHARS = 500;
|
||||
|
||||
interface AboutMeData {
|
||||
aboutMe: string | null;
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
coachFocus: string | null;
|
||||
pendingQuestions: string[];
|
||||
updatedAt: string | null;
|
||||
maxChars: number;
|
||||
fieldMaxChars: number;
|
||||
}
|
||||
|
||||
type StructuredKey = "conditions" | "allergies" | "coachFocus";
|
||||
|
||||
async function fetchAboutMe(): Promise<AboutMeData> {
|
||||
const res = await fetch("/api/coach/about-me");
|
||||
if (!res.ok) {
|
||||
@@ -40,6 +57,9 @@ async function fetchAboutMe(): Promise<AboutMeData> {
|
||||
return (await res.json()).data as AboutMeData;
|
||||
}
|
||||
|
||||
const FIELD_CLASSES =
|
||||
"border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
|
||||
|
||||
export function AboutMeSection({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
@@ -47,7 +67,12 @@ export function AboutMeSection({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<{
|
||||
aboutMe: string | null;
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
coachFocus: string | null;
|
||||
}>({ aboutMe: null, conditions: null, allergies: null, coachFocus: null });
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
@@ -55,64 +80,189 @@ export function AboutMeSection({
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const saved = query.data?.aboutMe ?? "";
|
||||
const saved = {
|
||||
aboutMe: query.data?.aboutMe ?? "",
|
||||
conditions: query.data?.conditions ?? "",
|
||||
allergies: query.data?.allergies ?? "",
|
||||
coachFocus: query.data?.coachFocus ?? "",
|
||||
};
|
||||
const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
|
||||
const value = draft ?? saved;
|
||||
// A draft equal to the server value renders identically and keeps the
|
||||
// save button disabled, so no effect is needed to clear it — the
|
||||
// save mutation's onSuccess resets the draft after a round-trip.
|
||||
const dirty = draft !== null && draft !== saved;
|
||||
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
|
||||
const pendingQuestions = query.data?.pendingQuestions ?? [];
|
||||
|
||||
const value = {
|
||||
aboutMe: drafts.aboutMe ?? saved.aboutMe,
|
||||
conditions: drafts.conditions ?? saved.conditions,
|
||||
allergies: drafts.allergies ?? saved.allergies,
|
||||
coachFocus: drafts.coachFocus ?? saved.coachFocus,
|
||||
};
|
||||
const dirty =
|
||||
value.aboutMe !== saved.aboutMe ||
|
||||
value.conditions !== saved.conditions ||
|
||||
value.allergies !== saved.allergies ||
|
||||
value.coachFocus !== saved.coachFocus;
|
||||
const hasAnyContent =
|
||||
saved.aboutMe.length > 0 ||
|
||||
saved.conditions.length > 0 ||
|
||||
saved.allergies.length > 0 ||
|
||||
saved.coachFocus.length > 0;
|
||||
|
||||
// Quiet completeness meter: one segment per answered field. Reads
|
||||
// the SAVED state (not the draft) so it only moves on persistence.
|
||||
const answered = [
|
||||
saved.conditions,
|
||||
saved.allergies,
|
||||
saved.coachFocus,
|
||||
saved.aboutMe,
|
||||
].filter((v) => v.length > 0).length;
|
||||
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: async (aboutMe: string) => {
|
||||
mutationFn: async (input: {
|
||||
aboutMe: string;
|
||||
conditions: string;
|
||||
allergies: string;
|
||||
coachFocus: string;
|
||||
}) => {
|
||||
const res = await fetch("/api/coach/about-me", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ aboutMe }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return ((await res.json()).data as AboutMeData).aboutMe ?? "";
|
||||
return (await res.json()).data as AboutMeData;
|
||||
},
|
||||
onSuccess: (nextSaved) => {
|
||||
onSuccess: (next) => {
|
||||
const cleared =
|
||||
!next.aboutMe &&
|
||||
!next.conditions &&
|
||||
!next.allergies &&
|
||||
!next.coachFocus;
|
||||
toast.success(
|
||||
nextSaved.length > 0
|
||||
? t("settings.ai.aboutMe.savedToast")
|
||||
: t("settings.ai.aboutMe.clearedToast"),
|
||||
cleared
|
||||
? t("settings.ai.aboutMe.clearedToast")
|
||||
: t("settings.ai.aboutMe.savedToast"),
|
||||
);
|
||||
setDraft(null);
|
||||
setDrafts({
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachAboutMeQuestions(),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("settings.ai.aboutMe.saveError"));
|
||||
},
|
||||
});
|
||||
|
||||
const disabled = !isAuthenticated || query.isLoading || save.isPending;
|
||||
|
||||
function structuredField(
|
||||
key: StructuredKey,
|
||||
label: string,
|
||||
placeholder: string,
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`settings-about-me-${key}`}>{label}</Label>
|
||||
<textarea
|
||||
id={`settings-about-me-${key}`}
|
||||
data-testid={`settings-about-me-${key}`}
|
||||
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
|
||||
rows={2}
|
||||
value={value[key]}
|
||||
maxLength={fieldMaxChars}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({ ...d, [key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-ai-about-me-title"
|
||||
data-testid="settings-about-me-card"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-6"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={NotebookPen}
|
||||
title={t("settings.ai.aboutMe.title")}
|
||||
titleId="settings-ai-about-me-title"
|
||||
description={t("settings.ai.aboutMe.description")}
|
||||
/>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<SettingsCardHeader
|
||||
icon={NotebookPen}
|
||||
title={t("settings.ai.aboutMe.title")}
|
||||
titleId="settings-ai-about-me-title"
|
||||
description={t("settings.ai.aboutMe.description")}
|
||||
/>
|
||||
{/* Completeness — four quiet segments, no percentage shouting. */}
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
aria-label={t("settings.ai.aboutMe.completeness", {
|
||||
answered,
|
||||
total: 4,
|
||||
})}
|
||||
title={t("settings.ai.aboutMe.completeness", {
|
||||
answered,
|
||||
total: 4,
|
||||
})}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-1 w-5 rounded-full transition-colors",
|
||||
i < answered ? "bg-dracula-purple" : "bg-border",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
data-testid="settings-about-me-textarea"
|
||||
className="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-36 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none"
|
||||
value={value}
|
||||
maxLength={maxChars}
|
||||
placeholder={t("settings.ai.aboutMe.placeholder")}
|
||||
disabled={!isAuthenticated || query.isLoading || save.isPending}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label={t("settings.ai.aboutMe.title")}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{structuredField(
|
||||
"conditions",
|
||||
t("settings.ai.aboutMe.conditionsLabel"),
|
||||
t("settings.ai.aboutMe.conditionsPlaceholder"),
|
||||
)}
|
||||
{structuredField(
|
||||
"allergies",
|
||||
t("settings.ai.aboutMe.allergiesLabel"),
|
||||
t("settings.ai.aboutMe.allergiesPlaceholder"),
|
||||
)}
|
||||
<div className="sm:col-span-2">
|
||||
{structuredField(
|
||||
"coachFocus",
|
||||
t("settings.ai.aboutMe.focusLabel"),
|
||||
t("settings.ai.aboutMe.focusPlaceholder"),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="settings-about-me-freetext">
|
||||
{t("settings.ai.aboutMe.freeTextLabel")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="settings-about-me-freetext"
|
||||
data-testid="settings-about-me-textarea"
|
||||
className={cn(FIELD_CLASSES, "min-h-36 resize-y")}
|
||||
value={value.aboutMe}
|
||||
maxLength={maxChars}
|
||||
placeholder={t("settings.ai.aboutMe.placeholder")}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({ ...d, aboutMe: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p
|
||||
@@ -120,19 +270,26 @@ export function AboutMeSection({
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{t("settings.ai.aboutMe.charCount", {
|
||||
used: value.length,
|
||||
used: value.aboutMe.length,
|
||||
max: maxChars,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{saved.length > 0 && (
|
||||
{hasAnyContent && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="settings-about-me-clear"
|
||||
disabled={!isAuthenticated || save.isPending}
|
||||
onClick={() => save.mutate("")}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
aboutMe: "",
|
||||
conditions: "",
|
||||
allergies: "",
|
||||
coachFocus: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("settings.ai.aboutMe.clear")}
|
||||
@@ -156,6 +313,34 @@ export function AboutMeSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingQuestions.length > 0 && (
|
||||
<div
|
||||
data-testid="settings-about-me-questions"
|
||||
className="border-dracula-purple/30 bg-dracula-purple/5 rounded-lg border p-4"
|
||||
>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<MessageCircleQuestion
|
||||
className="text-dracula-purple size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("settings.ai.aboutMe.questionsTitle")}
|
||||
</p>
|
||||
<ul className="text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm">
|
||||
{pendingQuestions.map((q) => (
|
||||
<li key={q}>{q}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs">
|
||||
<Link
|
||||
href="/insights/coach"
|
||||
className="text-dracula-purple underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("settings.ai.aboutMe.questionsOpenCoach")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.isError && (
|
||||
<p role="status" aria-live="polite" className="text-destructive text-xs">
|
||||
{t("settings.ai.aboutMe.loadError")}
|
||||
@@ -163,7 +348,7 @@ export function AboutMeSection({
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
|
||||
{t("settings.ai.aboutMe.hint")}
|
||||
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* v1.16.0 — structured self-context unit tests.
|
||||
*
|
||||
* Pins the pure pieces of the questionnaire feature without a DB:
|
||||
* - `deriveAgeYears` — whole-year age math incl. the pre-birthday edge.
|
||||
* - `composeSelfContextText` — merge order (profile facts → structured
|
||||
* answers → free text), the profile-facts-alone-stay-silent rule,
|
||||
* and the de/en label split.
|
||||
* - `clampPendingQuestions` — count + length caps, non-string drops.
|
||||
* - `parseQuestionsReply` — tolerant JSON-array extraction from a
|
||||
* model reply (fences, prose, garbage).
|
||||
* - `buildFallbackQuestions` — deterministic two-hint fallback in
|
||||
* priority order, localised.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
import {
|
||||
clampPendingQuestions,
|
||||
composeSelfContextText,
|
||||
deriveAgeYears,
|
||||
PENDING_QUESTION_MAX_CHARS,
|
||||
type SelfContext,
|
||||
} from "../about-me";
|
||||
import {
|
||||
buildFallbackQuestions,
|
||||
parseQuestionsReply,
|
||||
} from "../self-context-questions";
|
||||
|
||||
const emptyCtx: SelfContext = {
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
};
|
||||
|
||||
describe("deriveAgeYears", () => {
|
||||
const now = new Date("2026-06-10T12:00:00Z");
|
||||
|
||||
it("counts whole years after the birthday", () => {
|
||||
expect(deriveAgeYears(new Date("1980-06-01"), now)).toBe(46);
|
||||
});
|
||||
|
||||
it("does not count the year before the birthday", () => {
|
||||
expect(deriveAgeYears(new Date("1980-06-20"), now)).toBe(45);
|
||||
});
|
||||
|
||||
it("returns null for unknown or implausible dates", () => {
|
||||
expect(deriveAgeYears(null, now)).toBeNull();
|
||||
expect(deriveAgeYears(new Date("1850-01-01"), now)).toBeNull();
|
||||
expect(deriveAgeYears(new Date("2030-01-01"), now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeSelfContextText", () => {
|
||||
const profile = { ageYears: 46, gender: "MALE" };
|
||||
|
||||
it("stays null when the user wrote nothing — profile facts alone are not a self-description", () => {
|
||||
expect(composeSelfContextText(emptyCtx, profile, "de")).toBeNull();
|
||||
});
|
||||
|
||||
it("merges profile facts, structured answers, and free text in order (de labels)", () => {
|
||||
const text = composeSelfContextText(
|
||||
{
|
||||
aboutMe: "Schichtarbeit, Halbmarathon-Training.",
|
||||
conditions: "Hypertonie",
|
||||
allergies: "Laktose",
|
||||
coachFocus: "Blutdruck",
|
||||
},
|
||||
profile,
|
||||
"de",
|
||||
);
|
||||
expect(text).toBe(
|
||||
[
|
||||
"Profil: Alter: 46 · Geschlecht: männlich",
|
||||
"Chronische Erkrankungen: Hypertonie",
|
||||
"Allergien / Unverträglichkeiten: Laktose",
|
||||
"Darauf soll der Coach achten: Blutdruck",
|
||||
"",
|
||||
"Schichtarbeit, Halbmarathon-Training.",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses English labels for every non-de locale", () => {
|
||||
const text = composeSelfContextText(
|
||||
{ ...emptyCtx, conditions: "asthma" },
|
||||
{ ageYears: null, gender: "OTHER" },
|
||||
"pl",
|
||||
);
|
||||
expect(text).toBe(
|
||||
["Profile: Gender: non-binary", "Chronic conditions: asthma"].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips unknown gender codes instead of echoing them", () => {
|
||||
const text = composeSelfContextText(
|
||||
{ ...emptyCtx, aboutMe: "hello" },
|
||||
{ ageYears: null, gender: "SOMETHING_ELSE" },
|
||||
"en",
|
||||
);
|
||||
expect(text).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampPendingQuestions", () => {
|
||||
it("caps at 3 questions and per-question length", () => {
|
||||
const long = "x".repeat(PENDING_QUESTION_MAX_CHARS + 50);
|
||||
const result = clampPendingQuestions(["a", "b", long, "d"]);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[2]).toHaveLength(PENDING_QUESTION_MAX_CHARS);
|
||||
});
|
||||
|
||||
it("drops non-strings, blanks, and non-arrays", () => {
|
||||
expect(clampPendingQuestions([" ok ", 42, "", null])).toEqual(["ok"]);
|
||||
expect(clampPendingQuestions("not an array")).toEqual([]);
|
||||
expect(clampPendingQuestions(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuestionsReply", () => {
|
||||
it("parses a bare JSON array", () => {
|
||||
expect(parseQuestionsReply('["Frage 1?", "Frage 2?"]')).toEqual([
|
||||
"Frage 1?",
|
||||
"Frage 2?",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tolerates code fences and prose around the array", () => {
|
||||
const reply =
|
||||
'Sure! Here are the questions:\n```json\n["A?", "B?"]\n```\nHope that helps.';
|
||||
expect(parseQuestionsReply(reply)).toEqual(["A?", "B?"]);
|
||||
});
|
||||
|
||||
it("yields [] for garbage so the caller falls back", () => {
|
||||
expect(parseQuestionsReply("no array here")).toEqual([]);
|
||||
expect(parseQuestionsReply("[broken")).toEqual([]);
|
||||
expect(parseQuestionsReply('{"not": "an array"}')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFallbackQuestions", () => {
|
||||
it("returns the first two missing fields in priority order, localised", () => {
|
||||
const hints = buildFallbackQuestions(emptyCtx, "de");
|
||||
expect(hints).toEqual([
|
||||
"Hast du chronische Erkrankungen, die der Coach kennen sollte?",
|
||||
"Gibt es etwas Bestimmtes, worauf der Coach achten soll?",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips answered fields", () => {
|
||||
const hints = buildFallbackQuestions(
|
||||
{
|
||||
aboutMe: "text",
|
||||
conditions: "hypertension",
|
||||
allergies: null,
|
||||
coachFocus: "bp",
|
||||
},
|
||||
"en",
|
||||
);
|
||||
expect(hints).toEqual(["Do you have any allergies or intolerances?"]);
|
||||
});
|
||||
|
||||
it("returns nothing for a fully answered questionnaire", () => {
|
||||
expect(
|
||||
buildFallbackQuestions(
|
||||
{
|
||||
aboutMe: "a",
|
||||
conditions: "b",
|
||||
allergies: "c",
|
||||
coachFocus: "d",
|
||||
},
|
||||
"en",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
+252
-25
@@ -1,49 +1,274 @@
|
||||
/**
|
||||
* v1.15.20 — user-authored "about me" self-description for the AI surfaces.
|
||||
* v1.16.0 — extended into a structured self-context: alongside the free
|
||||
* text the user can answer three short questions (chronic conditions,
|
||||
* allergies/intolerances, what the Coach should watch). Age and gender
|
||||
* are NOT stored here — they live on the User profile and are merged
|
||||
* into the composed prompt text at assembly time, so the questionnaire
|
||||
* never duplicates profile data.
|
||||
*
|
||||
* The user writes a short free-text introduction (conditions they want the
|
||||
* Coach to know about, life context, goals — whatever they choose to share)
|
||||
* in Settings → AI. The text is stored encrypted at rest
|
||||
* (`UserHealthProfile.aboutMeEncrypted`, the shared Bytes codec) and is
|
||||
* injected into two prompts as a clearly delimited, user-provided context
|
||||
* block:
|
||||
* Storage: every field is encrypted at rest
|
||||
* (`UserHealthProfile.*Encrypted`, the shared Bytes codec) and injected
|
||||
* into two prompts as a clearly delimited, user-provided context block:
|
||||
*
|
||||
* - the Coach system prompt (`getCoachSystemPrompt`, third argument), and
|
||||
* - the daily-briefing user prompt (`/api/insights/generate` +
|
||||
* `comprehensive-generate.ts`).
|
||||
*
|
||||
* The block carries an explicit instruction frame: the text is the user's
|
||||
* OWN words, the single source for personal context, and nothing beyond it
|
||||
* OWN words, the single source for personal context, and nothing beyond it
|
||||
* may be invented. Reads are fail-closed per row — an undecryptable payload
|
||||
* (key rotated out of the map) yields `null` rather than ciphertext.
|
||||
*
|
||||
* `pendingQuestions` — up to 3 clarifying questions the server derives
|
||||
* after a save (see `self-context-questions.ts`); the Coach composer
|
||||
* renders them as tappable suggestion chips. Encrypted JSON string[].
|
||||
*
|
||||
* Server-only — reads `@/lib/db`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
decryptFromBytes,
|
||||
encryptToBytes,
|
||||
} from "@/lib/ai/coach/bytes-codec";
|
||||
|
||||
export { ABOUT_ME_MAX_CHARS } from "@/lib/validations/about-me";
|
||||
|
||||
export interface SelfContext {
|
||||
aboutMe: string | null;
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
coachFocus: string | null;
|
||||
}
|
||||
|
||||
function decryptOrNull(payload: Uint8Array | null): string | null {
|
||||
if (!payload) return null;
|
||||
try {
|
||||
const text = decryptFromBytes(payload).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
} catch {
|
||||
// Fail closed per field — never surface ciphertext, never throw
|
||||
// into a prompt-assembly path.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load + decrypt the caller's "about me" text. Returns `null` when the
|
||||
* user never wrote one, cleared it, or the payload no longer decrypts
|
||||
* (fail closed — never surface ciphertext, never throw into a prompt
|
||||
* assembly path).
|
||||
* Load + decrypt the caller's structured self-context. Every field is
|
||||
* independently fail-closed; a row that never existed yields all-null.
|
||||
*/
|
||||
export async function getAboutMeForUser(userId: string): Promise<string | null> {
|
||||
export async function getSelfContextForUser(
|
||||
userId: string,
|
||||
): Promise<SelfContext> {
|
||||
try {
|
||||
const row = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { aboutMeEncrypted: true },
|
||||
select: {
|
||||
aboutMeEncrypted: true,
|
||||
conditionsEncrypted: true,
|
||||
allergiesEncrypted: true,
|
||||
coachFocusEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (!row?.aboutMeEncrypted) return null;
|
||||
const text = decryptFromBytes(row.aboutMeEncrypted).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
return {
|
||||
aboutMe: decryptOrNull(row?.aboutMeEncrypted ?? null),
|
||||
conditions: decryptOrNull(row?.conditionsEncrypted ?? null),
|
||||
allergies: decryptOrNull(row?.allergiesEncrypted ?? null),
|
||||
coachFocus: decryptOrNull(row?.coachFocusEncrypted ?? null),
|
||||
};
|
||||
} catch {
|
||||
return { aboutMe: null, conditions: null, allergies: null, coachFocus: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat shim for callers that only need the free text (kept so
|
||||
* the questions generator can cheaply inspect the prose alone).
|
||||
*/
|
||||
export async function getAboutMeForUser(
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
return (await getSelfContextForUser(userId)).aboutMe;
|
||||
}
|
||||
|
||||
/** Whole years between `dateOfBirth` and `now`; null when unknown. */
|
||||
export function deriveAgeYears(
|
||||
dateOfBirth: Date | null,
|
||||
now: Date = new Date(),
|
||||
): number | null {
|
||||
if (!dateOfBirth) return null;
|
||||
let age = now.getFullYear() - dateOfBirth.getFullYear();
|
||||
const beforeBirthday =
|
||||
now.getMonth() < dateOfBirth.getMonth() ||
|
||||
(now.getMonth() === dateOfBirth.getMonth() &&
|
||||
now.getDate() < dateOfBirth.getDate());
|
||||
if (beforeBirthday) age -= 1;
|
||||
return age >= 0 && age < 130 ? age : null;
|
||||
}
|
||||
|
||||
const GENDER_LABELS: Record<"de" | "en", Record<string, string>> = {
|
||||
de: { MALE: "männlich", FEMALE: "weiblich", OTHER: "divers" },
|
||||
en: { MALE: "male", FEMALE: "female", OTHER: "non-binary" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Compose the merged self-context text the prompt block quotes: profile
|
||||
* facts first (age/gender from the User row — the single source, never
|
||||
* duplicated into the questionnaire), then the structured answers, then
|
||||
* the free text. Returns `null` when there is nothing to say — the
|
||||
* prompt assembly skips the whole block in that case.
|
||||
*
|
||||
* Labels follow the de/en split of the surrounding block builders; the
|
||||
* quoted values themselves stay in whatever language the user wrote.
|
||||
*/
|
||||
export function composeSelfContextText(
|
||||
ctx: SelfContext,
|
||||
profile: { ageYears: number | null; gender: string | null },
|
||||
locale: string,
|
||||
): string | null {
|
||||
const de = locale === "de";
|
||||
const lines: string[] = [];
|
||||
|
||||
const facts: string[] = [];
|
||||
if (profile.ageYears !== null) {
|
||||
facts.push(de ? `Alter: ${profile.ageYears}` : `Age: ${profile.ageYears}`);
|
||||
}
|
||||
if (profile.gender) {
|
||||
const label =
|
||||
GENDER_LABELS[de ? "de" : "en"][profile.gender.toUpperCase()] ?? null;
|
||||
if (label) {
|
||||
facts.push(de ? `Geschlecht: ${label}` : `Gender: ${label}`);
|
||||
}
|
||||
}
|
||||
if (facts.length > 0) {
|
||||
lines.push(
|
||||
(de ? "Profil: " : "Profile: ") + facts.join(" · "),
|
||||
);
|
||||
}
|
||||
|
||||
if (ctx.conditions) {
|
||||
lines.push(
|
||||
(de ? "Chronische Erkrankungen: " : "Chronic conditions: ") +
|
||||
ctx.conditions,
|
||||
);
|
||||
}
|
||||
if (ctx.allergies) {
|
||||
lines.push(
|
||||
(de
|
||||
? "Allergien / Unverträglichkeiten: "
|
||||
: "Allergies / intolerances: ") + ctx.allergies,
|
||||
);
|
||||
}
|
||||
if (ctx.coachFocus) {
|
||||
lines.push(
|
||||
(de ? "Darauf soll der Coach achten: " : "The Coach should watch: ") +
|
||||
ctx.coachFocus,
|
||||
);
|
||||
}
|
||||
if (ctx.aboutMe) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(ctx.aboutMe);
|
||||
}
|
||||
|
||||
// Profile facts alone are not a self-description — without at least
|
||||
// one user-authored field the block stays absent, exactly like before
|
||||
// the structured fields existed.
|
||||
const hasUserContent =
|
||||
ctx.aboutMe !== null ||
|
||||
ctx.conditions !== null ||
|
||||
ctx.allergies !== null ||
|
||||
ctx.coachFocus !== null;
|
||||
if (!hasUserContent) return null;
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* One-call variant for the prompt-assembly paths: load the structured
|
||||
* context + the profile facts and return the composed text (or `null`
|
||||
* when the user never wrote anything). Replaces the v1.15.20
|
||||
* `getAboutMeForUser` call sites.
|
||||
*/
|
||||
export async function getSelfContextTextForUser(
|
||||
userId: string,
|
||||
locale: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const [ctx, user] = await Promise.all([
|
||||
getSelfContextForUser(userId),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dateOfBirth: true, gender: true },
|
||||
}),
|
||||
]);
|
||||
return composeSelfContextText(
|
||||
ctx,
|
||||
{
|
||||
ageYears: deriveAgeYears(user?.dateOfBirth ?? null),
|
||||
gender: user?.gender ?? null,
|
||||
},
|
||||
locale,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hard caps on the persisted clarifying questions. */
|
||||
export const PENDING_QUESTIONS_MAX = 3;
|
||||
export const PENDING_QUESTION_MAX_CHARS = 200;
|
||||
|
||||
/** Clamp + sanitise a candidate question list before persisting. */
|
||||
export function clampPendingQuestions(questions: unknown): string[] {
|
||||
if (!Array.isArray(questions)) return [];
|
||||
return questions
|
||||
.filter((q): q is string => typeof q === "string")
|
||||
.map((q) => q.trim())
|
||||
.filter((q) => q.length > 0)
|
||||
.map((q) => q.slice(0, PENDING_QUESTION_MAX_CHARS))
|
||||
.slice(0, PENDING_QUESTIONS_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the caller's pending clarifying questions. Fail-closed: a
|
||||
* missing row, NULL payload, or undecryptable ciphertext yields `[]`.
|
||||
*/
|
||||
export async function getPendingQuestionsForUser(
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const row = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { pendingQuestionsEncrypted: true },
|
||||
});
|
||||
if (!row?.pendingQuestionsEncrypted) return [];
|
||||
return clampPendingQuestions(
|
||||
JSON.parse(decryptFromBytes(row.pendingQuestionsEncrypted)),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist (or clear, with `null` / `[]`) the pending questions.
|
||||
* Upserts so a user who never saved a self-context can still receive
|
||||
* the deterministic fallback hints.
|
||||
*/
|
||||
export async function setPendingQuestionsForUser(
|
||||
userId: string,
|
||||
questions: string[] | null,
|
||||
): Promise<void> {
|
||||
const clamped = questions === null ? [] : clampPendingQuestions(questions);
|
||||
const payload =
|
||||
clamped.length > 0 ? encryptToBytes(JSON.stringify(clamped)) : null;
|
||||
await prisma.userHealthProfile.upsert({
|
||||
where: { userId },
|
||||
create: { userId, pendingQuestionsEncrypted: payload },
|
||||
update: { pendingQuestionsEncrypted: payload },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the delimited context block the briefing user prompt appends
|
||||
* (the GLP-1-plateau / derived-signal precedent: a SYSTEM CONTEXT block
|
||||
@@ -62,10 +287,11 @@ SYSTEM CONTEXT — SELBSTAUSKUNFT (vom Nutzer bereitgestellt):
|
||||
"""
|
||||
${aboutMe}
|
||||
"""
|
||||
Dieser Text stammt wörtlich vom Nutzer und ist die EINZIGE Quelle für
|
||||
persönlichen Kontext jenseits der Messdaten. Nutze ihn, um die Einordnung
|
||||
zu personalisieren. Behandle ihn beschreibend, nie diagnostisch. Erfinde
|
||||
nichts, was weder in den Daten noch in diesem Text steht.`;
|
||||
Dieser Text stammt vom Nutzer (plus Alter/Geschlecht aus dem Profil) und
|
||||
ist die EINZIGE Quelle für persönlichen Kontext jenseits der Messdaten.
|
||||
Nutze ihn, um die Einordnung zu personalisieren. Behandle ihn
|
||||
beschreibend, nie diagnostisch. Erfinde nichts, was weder in den Daten
|
||||
noch in diesem Text steht.`;
|
||||
}
|
||||
return `
|
||||
|
||||
@@ -73,8 +299,9 @@ SYSTEM CONTEXT — ABOUT ME (provided by the user):
|
||||
"""
|
||||
${aboutMe}
|
||||
"""
|
||||
This text is the user's own words and the ONLY source of personal context
|
||||
beyond the measurement data. Use it to personalise the assessment. Treat
|
||||
it as descriptive, never diagnostic. Do not invent anything that is in
|
||||
neither the data nor this text.`;
|
||||
This text comes from the user (plus age/gender from their profile) and
|
||||
is the ONLY source of personal context beyond the measurement data. Use
|
||||
it to personalise the assessment. Treat it as descriptive, never
|
||||
diagnostic. Do not invent anything that is in neither the data nor this
|
||||
text.`;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* v1.16.0 — clarifying questions for the self-context questionnaire.
|
||||
*
|
||||
* After the user saves Settings → AI → "About me", the server derives
|
||||
* up to 3 short follow-up questions that would best complete the
|
||||
* picture for the Coach, and persists them encrypted
|
||||
* (`UserHealthProfile.pendingQuestionsEncrypted`). The Coach composer
|
||||
* renders them as tappable suggestion chips; answering or dismissing a
|
||||
* chip removes it.
|
||||
*
|
||||
* Two paths, gated in order:
|
||||
* 1. AI path — only when the user has a working provider
|
||||
* (`hasAnyConfiguredProvider`) AND the daily Coach token budget
|
||||
* is not exhausted. One single-shot completion (no fallback
|
||||
* chain — this is a nicety, not a core flow); spend is recorded
|
||||
* against the same per-day ledger the Coach uses.
|
||||
* 2. Deterministic path — two static completion hints for the first
|
||||
* two unanswered fields, localised through the server translator.
|
||||
* Also the landing spot for every AI-path failure (no provider,
|
||||
* budget gone, model error, unparseable output).
|
||||
*
|
||||
* Server-only — reads `@/lib/db` through the provider resolver.
|
||||
*/
|
||||
import {
|
||||
hasAnyConfiguredProvider,
|
||||
resolveProvider,
|
||||
} from "@/lib/ai/provider";
|
||||
import {
|
||||
buildDateKey,
|
||||
getDailyTokenSpend,
|
||||
MAX_TOKENS_PER_USER_PER_DAY,
|
||||
recordSpend,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import {
|
||||
clampPendingQuestions,
|
||||
type SelfContext,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale, locales } from "@/lib/i18n/config";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/** Token ceiling for the single-shot questions completion. */
|
||||
const QUESTIONS_MAX_TOKENS = 300;
|
||||
|
||||
const LANGUAGE_NAMES: Record<Locale, string> = {
|
||||
de: "German",
|
||||
en: "English",
|
||||
es: "Spanish",
|
||||
fr: "French",
|
||||
it: "Italian",
|
||||
pl: "Polish",
|
||||
};
|
||||
|
||||
function resolveLocale(locale: string | null | undefined): Locale {
|
||||
return locales.includes(locale as Locale)
|
||||
? (locale as Locale)
|
||||
: defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic fallback: completion hints for the first two unanswered
|
||||
* fields, in a fixed priority order (conditions → focus → allergies →
|
||||
* free text). Fully answered questionnaires get no hints — there is
|
||||
* nothing left to complete.
|
||||
*/
|
||||
export function buildFallbackQuestions(
|
||||
ctx: SelfContext,
|
||||
locale: string | null | undefined,
|
||||
): string[] {
|
||||
const t = getServerTranslator(resolveLocale(locale)).t;
|
||||
const hints: string[] = [];
|
||||
if (!ctx.conditions) hints.push(t("coachNudges.questionConditions"));
|
||||
if (!ctx.coachFocus) hints.push(t("coachNudges.questionFocus"));
|
||||
if (!ctx.allergies) hints.push(t("coachNudges.questionAllergies"));
|
||||
if (!ctx.aboutMe) hints.push(t("coachNudges.questionAboutMe"));
|
||||
return hints.slice(0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a string[] from a model reply that should be a JSON array.
|
||||
* Tolerates code fences and prose around the array; everything else
|
||||
* yields `[]` so the caller falls back deterministically.
|
||||
*/
|
||||
export function parseQuestionsReply(content: string): string[] {
|
||||
const start = content.indexOf("[");
|
||||
const end = content.lastIndexOf("]");
|
||||
if (start === -1 || end === -1 || end <= start) return [];
|
||||
try {
|
||||
return clampPendingQuestions(JSON.parse(content.slice(start, end + 1)));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompts(
|
||||
ctx: SelfContext,
|
||||
locale: Locale,
|
||||
): { systemPrompt: string; userPrompt: string } {
|
||||
const language = LANGUAGE_NAMES[locale];
|
||||
const systemPrompt = `You help complete a health-app user's self-description for their AI health coach. Based on what the user has already shared, write AT MOST 3 short, specific follow-up questions that would best complete the picture (e.g. unstated conditions a stated medication implies, missing context for a stated goal). Never ask for anything already answered. Never ask for a diagnosis. Write the questions in ${language}, addressed informally to the user. Reply with ONLY a JSON array of strings — no prose, no code fences.`;
|
||||
|
||||
const fieldLines = [
|
||||
`conditions: ${ctx.conditions ?? "(not answered)"}`,
|
||||
`allergies/intolerances: ${ctx.allergies ?? "(not answered)"}`,
|
||||
`coach focus: ${ctx.coachFocus ?? "(not answered)"}`,
|
||||
`free text: ${ctx.aboutMe ?? "(not answered)"}`,
|
||||
];
|
||||
return { systemPrompt, userPrompt: fieldLines.join("\n") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the pending questions for a freshly saved self-context.
|
||||
* Never throws — every failure path lands on the deterministic
|
||||
* fallback so the PUT route stays robust.
|
||||
*/
|
||||
export async function deriveClarifyingQuestions(
|
||||
userId: string,
|
||||
ctx: SelfContext,
|
||||
locale: string | null | undefined,
|
||||
): Promise<{ questions: string[]; source: "ai" | "fallback" }> {
|
||||
const fallback = () => ({
|
||||
questions: buildFallbackQuestions(ctx, locale),
|
||||
source: "fallback" as const,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!(await hasAnyConfiguredProvider(userId))) return fallback();
|
||||
|
||||
// Budget gate — same per-day ledger the Coach chat enforces.
|
||||
const dateKey = buildDateKey();
|
||||
const spent = await getDailyTokenSpend(userId, dateKey);
|
||||
if (spent >= MAX_TOKENS_PER_USER_PER_DAY) return fallback();
|
||||
|
||||
const provider = await resolveProvider(userId);
|
||||
const { systemPrompt, userPrompt } = buildPrompts(
|
||||
ctx,
|
||||
resolveLocale(locale),
|
||||
);
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
temperature: 0.4,
|
||||
maxTokens: QUESTIONS_MAX_TOKENS,
|
||||
});
|
||||
if (typeof result.tokensUsed === "number" && result.tokensUsed > 0) {
|
||||
await recordSpend({ userId, tokens: result.tokensUsed, dateKey });
|
||||
}
|
||||
|
||||
const questions = parseQuestionsReply(result.content);
|
||||
if (questions.length === 0) return fallback();
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
self_context_questions_source: "ai",
|
||||
self_context_questions_count: questions.length,
|
||||
},
|
||||
});
|
||||
return { questions, source: "ai" };
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* v1.16.0 — invite-token consume-path unit tests.
|
||||
*
|
||||
* Pins the gate order against a mocked Prisma client:
|
||||
* - shape gate (`looksLikeInviteToken`) short-circuits before any DB hit,
|
||||
* - revoked > expired > exhausted refusal precedence,
|
||||
* - the guarded-increment WHERE carries `revokedAt: null`,
|
||||
* - `recordInviteConsumer` writes the `usedBy` stamp AND the
|
||||
* redemption-ledger row in one transaction.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findUnique = vi.fn();
|
||||
const updateMany = vi.fn();
|
||||
const update = vi.fn();
|
||||
const redemptionCreate = vi.fn();
|
||||
const transaction = vi.fn(async (ops: unknown[]) => ops);
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inviteToken: {
|
||||
findUnique: (...args: unknown[]) => findUnique(...args),
|
||||
updateMany: (...args: unknown[]) => updateMany(...args),
|
||||
update: (...args: unknown[]) => update(...args),
|
||||
},
|
||||
inviteRedemption: {
|
||||
create: (...args: unknown[]) => redemptionCreate(...args),
|
||||
},
|
||||
$transaction: (...args: unknown[]) => transaction(...(args as [unknown[]])),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
consumeInviteToken,
|
||||
generateInviteToken,
|
||||
looksLikeInviteToken,
|
||||
recordInviteConsumer,
|
||||
} from "../invite-token";
|
||||
|
||||
const FUTURE = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
const PAST = new Date(Date.now() - 60 * 60 * 1000);
|
||||
|
||||
function baseInvite(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "inv1",
|
||||
expiresAt: FUTURE,
|
||||
uses: 0,
|
||||
maxUses: 1,
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.API_TOKEN_HMAC_KEY = "0".repeat(64);
|
||||
});
|
||||
|
||||
describe("consumeInviteToken", () => {
|
||||
it("rejects malformed tokens before touching the DB", async () => {
|
||||
const result = await consumeInviteToken("not-a-token");
|
||||
expect(result).toEqual({ ok: false, reason: "not_found" });
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a revoked invite ahead of every other state", async () => {
|
||||
findUnique.mockResolvedValue(
|
||||
baseInvite({ revokedAt: PAST, expiresAt: PAST, uses: 5, maxUses: 1 }),
|
||||
);
|
||||
const result = await consumeInviteToken(generateInviteToken());
|
||||
expect(result).toEqual({ ok: false, reason: "revoked" });
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an expired invite", async () => {
|
||||
findUnique.mockResolvedValue(baseInvite({ expiresAt: PAST }));
|
||||
const result = await consumeInviteToken(generateInviteToken());
|
||||
expect(result).toEqual({ ok: false, reason: "expired" });
|
||||
});
|
||||
|
||||
it("refuses an exhausted invite", async () => {
|
||||
findUnique.mockResolvedValue(baseInvite({ uses: 1, maxUses: 1 }));
|
||||
const result = await consumeInviteToken(generateInviteToken());
|
||||
expect(result).toEqual({ ok: false, reason: "exhausted" });
|
||||
});
|
||||
|
||||
it("consumes a valid invite with a revocation-guarded increment", async () => {
|
||||
findUnique.mockResolvedValue(baseInvite());
|
||||
updateMany.mockResolvedValue({ count: 1 });
|
||||
const result = await consumeInviteToken(generateInviteToken());
|
||||
expect(result).toEqual({ ok: true, inviteId: "inv1" });
|
||||
const where = updateMany.mock.calls[0][0].where;
|
||||
expect(where.revokedAt).toBeNull();
|
||||
expect(where.uses).toEqual({ lt: 1 });
|
||||
});
|
||||
|
||||
it("treats a lost increment race as exhausted", async () => {
|
||||
findUnique.mockResolvedValue(baseInvite({ maxUses: 2, uses: 1 }));
|
||||
updateMany.mockResolvedValue({ count: 0 });
|
||||
const result = await consumeInviteToken(generateInviteToken());
|
||||
expect(result).toEqual({ ok: false, reason: "exhausted" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordInviteConsumer", () => {
|
||||
it("stamps usedBy and appends a redemption-ledger row", async () => {
|
||||
await recordInviteConsumer("inv1", "user1");
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "inv1" },
|
||||
data: { usedBy: "user1" },
|
||||
});
|
||||
expect(redemptionCreate).toHaveBeenCalledWith({
|
||||
data: { inviteId: "inv1", userId: "user1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("swallows failures — the use is already counted", async () => {
|
||||
transaction.mockRejectedValueOnce(new Error("boom"));
|
||||
await expect(recordInviteConsumer("inv1", "user1")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeInviteToken", () => {
|
||||
it("accepts the generated shape and rejects near-misses", () => {
|
||||
expect(looksLikeInviteToken(generateInviteToken())).toBe(true);
|
||||
expect(looksLikeInviteToken("hlk_" + "a".repeat(64))).toBe(false);
|
||||
expect(looksLikeInviteToken("hlv_" + "a".repeat(63))).toBe(false);
|
||||
expect(looksLikeInviteToken("hlv_" + "G".repeat(64))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ export type InviteConsumeResult =
|
||||
| { ok: true; inviteId: string }
|
||||
| {
|
||||
ok: false;
|
||||
reason: "not_found" | "expired" | "exhausted";
|
||||
reason: "not_found" | "expired" | "exhausted" | "revoked";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -85,11 +85,22 @@ export async function consumeInviteToken(
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const invite = await prisma.inviteToken.findUnique({
|
||||
where: { tokenHash },
|
||||
select: { id: true, expiresAt: true, uses: true, maxUses: true },
|
||||
select: {
|
||||
id: true,
|
||||
expiresAt: true,
|
||||
uses: true,
|
||||
maxUses: true,
|
||||
revokedAt: true,
|
||||
},
|
||||
});
|
||||
if (!invite) return { ok: false, reason: "not_found" };
|
||||
|
||||
const now = new Date();
|
||||
// v1.16.0 — a revoked invite is refused like an expired one; the row
|
||||
// stays so the admin table keeps its redemption history visible.
|
||||
if (invite.revokedAt !== null) {
|
||||
return { ok: false, reason: "revoked" };
|
||||
}
|
||||
if (invite.expiresAt.getTime() <= now.getTime()) {
|
||||
return { ok: false, reason: "expired" };
|
||||
}
|
||||
@@ -101,6 +112,7 @@ export async function consumeInviteToken(
|
||||
where: {
|
||||
id: invite.id,
|
||||
expiresAt: { gt: now },
|
||||
revokedAt: null,
|
||||
// Guarded increment — Postgres evaluates the predicate and the
|
||||
// UPDATE atomically, so a concurrent consumer of the last use
|
||||
// makes this a no-op instead of an overshoot.
|
||||
@@ -119,20 +131,28 @@ export async function consumeInviteToken(
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the consumer account onto the invite after the user row exists.
|
||||
* Best-effort + last-consumer-wins on multi-use invites; a failure here
|
||||
* never unwinds the registration.
|
||||
* Stamp the consumer account onto the invite after the user row exists
|
||||
* AND append a row to the per-signup redemption ledger (v1.16.0 —
|
||||
* `usedBy` only ever holds the LAST consumer; the ledger holds them
|
||||
* all, so multi-use invites keep their full history). Best-effort; a
|
||||
* failure here never unwinds the registration.
|
||||
*/
|
||||
export async function recordInviteConsumer(
|
||||
inviteId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await prisma.inviteToken.update({
|
||||
where: { id: inviteId },
|
||||
data: { usedBy: userId },
|
||||
});
|
||||
await prisma.$transaction([
|
||||
prisma.inviteToken.update({
|
||||
where: { id: inviteId },
|
||||
data: { usedBy: userId },
|
||||
}),
|
||||
prisma.inviteRedemption.create({
|
||||
data: { inviteId, userId },
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// Informational column only — the use itself is already counted.
|
||||
// Informational rows only — the use itself is already counted by
|
||||
// the guarded increment in `consumeInviteToken`.
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
|
||||
import {
|
||||
buildAboutMeInsightBlock,
|
||||
getAboutMeForUser,
|
||||
getSelfContextTextForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
@@ -569,7 +569,7 @@ export async function generateComprehensiveInsight(
|
||||
// v1.15.20 — fold the user-authored "about me" self-description
|
||||
// (Settings → AI) into the nightly briefing exactly like the
|
||||
// on-demand route. Null (no text / undecryptable) costs nothing.
|
||||
const aboutMe = await getAboutMeForUser(userId);
|
||||
const aboutMe = await getSelfContextTextForUser(userId, locale);
|
||||
if (aboutMe) {
|
||||
userPrompt += buildAboutMeInsightBlock(aboutMe, locale);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
evaluateBpTrigger,
|
||||
evaluateComplianceTrigger,
|
||||
evaluateScoreTrigger,
|
||||
evaluateSelfContextTrigger,
|
||||
} from "../coach-nudge";
|
||||
|
||||
function dose(taken: boolean, skipped = false) {
|
||||
@@ -137,10 +138,77 @@ describe("buildCoachNudgePayload", () => {
|
||||
|
||||
it("produces a distinct payload per trigger", () => {
|
||||
const titles = new Set(
|
||||
(["compliance", "bp", "score"] as const).map(
|
||||
(["compliance", "bp", "score", "selfContext"] as const).map(
|
||||
(trigger) => buildCoachNudgePayload(trigger, "en").title,
|
||||
),
|
||||
);
|
||||
expect(titles.size).toBe(3);
|
||||
expect(titles.size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateSelfContextTrigger", () => {
|
||||
const now = new Date("2026-06-10T05:15:00Z");
|
||||
const daysAgo = (days: number) =>
|
||||
new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
const fullProfile = (updatedAt: Date) => ({
|
||||
hasAboutMe: true,
|
||||
hasConditions: true,
|
||||
hasAllergies: true,
|
||||
hasCoachFocus: true,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
it("never fires without recent Coach activity", () => {
|
||||
expect(
|
||||
evaluateSelfContextTrigger(
|
||||
{ profile: null, lastCoachUseAt: null },
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateSelfContextTrigger(
|
||||
{ profile: null, lastCoachUseAt: daysAgo(15) },
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fires for an active user with no profile at all", () => {
|
||||
expect(
|
||||
evaluateSelfContextTrigger(
|
||||
{ profile: null, lastCoachUseAt: daysAgo(2) },
|
||||
now,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fires for an incomplete profile", () => {
|
||||
expect(
|
||||
evaluateSelfContextTrigger(
|
||||
{
|
||||
profile: { ...fullProfile(daysAgo(1)), hasAllergies: false },
|
||||
lastCoachUseAt: daysAgo(2),
|
||||
},
|
||||
now,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fires for a complete-but-stale profile (60+ days)", () => {
|
||||
expect(
|
||||
evaluateSelfContextTrigger(
|
||||
{ profile: fullProfile(daysAgo(61)), lastCoachUseAt: daysAgo(2) },
|
||||
now,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays silent for a complete, fresh profile", () => {
|
||||
expect(
|
||||
evaluateSelfContextTrigger(
|
||||
{ profile: fullProfile(daysAgo(10)), lastCoachUseAt: daysAgo(2) },
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,11 @@
|
||||
* - `score`: recovery score falling sharply — the last 7-day mean
|
||||
* sits ≥ 15 points under the previous 7-day mean (≥ 3 samples in
|
||||
* each window).
|
||||
* - `selfContext` (v1.16.0): the self-context questionnaire is
|
||||
* incomplete or 60+ days stale AND the user actively talks to the
|
||||
* Coach (a CoachUsage row in the last 14 days) — a gentle check-up
|
||||
* nudge to refresh the personal context the Coach reads. Presence
|
||||
* is checked on the encrypted columns only; nothing is decrypted.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
@@ -67,8 +72,12 @@ export const COACH_NUDGE_BP_MIN_READINGS = 3;
|
||||
export const COACH_NUDGE_SCORE_DROP = 15;
|
||||
/** Score trigger: minimum samples per weekly window. */
|
||||
export const COACH_NUDGE_SCORE_MIN_SAMPLES = 3;
|
||||
/** Self-context trigger: profile older than this counts as stale. */
|
||||
export const COACH_NUDGE_SELF_CONTEXT_STALE_DAYS = 60;
|
||||
/** Self-context trigger: Coach usage within this window counts as active. */
|
||||
export const COACH_NUDGE_COACH_ACTIVE_DAYS = 14;
|
||||
|
||||
export type CoachNudgeTrigger = "compliance" | "bp" | "score";
|
||||
export type CoachNudgeTrigger = "compliance" | "bp" | "score" | "selfContext";
|
||||
|
||||
export interface CoachNudgeSummary {
|
||||
candidatesScanned: number;
|
||||
@@ -114,6 +123,49 @@ export function evaluateBpTrigger(
|
||||
return mean > greenMax;
|
||||
}
|
||||
|
||||
export interface SelfContextTriggerInput {
|
||||
/** Presence-only view of the questionnaire (encrypted columns). */
|
||||
profile: {
|
||||
hasAboutMe: boolean;
|
||||
hasConditions: boolean;
|
||||
hasAllergies: boolean;
|
||||
hasCoachFocus: boolean;
|
||||
updatedAt: Date;
|
||||
} | null;
|
||||
/** Most recent CoachUsage activity, or null when never used. */
|
||||
lastCoachUseAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.0 — fourth trigger: the self-context is incomplete (any of the
|
||||
* four fields empty, or no profile at all) or stale (60+ days), AND
|
||||
* the user actively talks to the Coach. Without the activity gate this
|
||||
* would nag every passive account forever.
|
||||
*/
|
||||
export function evaluateSelfContextTrigger(
|
||||
input: SelfContextTriggerInput,
|
||||
now: Date,
|
||||
): boolean {
|
||||
const { profile, lastCoachUseAt } = input;
|
||||
if (
|
||||
lastCoachUseAt === null ||
|
||||
now.getTime() - lastCoachUseAt.getTime() >
|
||||
COACH_NUDGE_COACH_ACTIVE_DAYS * MS_PER_DAY
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (profile === null) return true;
|
||||
const incomplete =
|
||||
!profile.hasAboutMe ||
|
||||
!profile.hasConditions ||
|
||||
!profile.hasAllergies ||
|
||||
!profile.hasCoachFocus;
|
||||
const stale =
|
||||
now.getTime() - profile.updatedAt.getTime() >
|
||||
COACH_NUDGE_SELF_CONTEXT_STALE_DAYS * MS_PER_DAY;
|
||||
return incomplete || stale;
|
||||
}
|
||||
|
||||
export function evaluateScoreTrigger(
|
||||
recentValues: number[],
|
||||
priorValues: number[],
|
||||
@@ -155,6 +207,11 @@ export function buildCoachNudgePayload(
|
||||
title: t("coachNudges.scoreTitle"),
|
||||
body: t("coachNudges.scoreBody"),
|
||||
};
|
||||
case "selfContext":
|
||||
return {
|
||||
title: t("coachNudges.selfContextTitle"),
|
||||
body: t("coachNudges.selfContextBody"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +292,46 @@ export async function findTriggerForUser(
|
||||
.map((m) => m.value);
|
||||
if (evaluateScoreTrigger(recent, prior)) return "score";
|
||||
|
||||
// 4) Self-context incomplete / stale while the Coach is in active
|
||||
// use. Presence-only reads on the encrypted columns — nothing is
|
||||
// decrypted in this job.
|
||||
const coachActiveCutoff = new Date(
|
||||
now.getTime() - COACH_NUDGE_COACH_ACTIVE_DAYS * MS_PER_DAY,
|
||||
);
|
||||
const recentUsage = await prisma.coachUsage.findFirst({
|
||||
where: { userId: user.id, updatedAt: { gte: coachActiveCutoff } },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
if (recentUsage) {
|
||||
const profileRow = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
aboutMeEncrypted: true,
|
||||
conditionsEncrypted: true,
|
||||
allergiesEncrypted: true,
|
||||
coachFocusEncrypted: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
const triggered = evaluateSelfContextTrigger(
|
||||
{
|
||||
profile: profileRow
|
||||
? {
|
||||
hasAboutMe: profileRow.aboutMeEncrypted !== null,
|
||||
hasConditions: profileRow.conditionsEncrypted !== null,
|
||||
hasAllergies: profileRow.allergiesEncrypted !== null,
|
||||
hasCoachFocus: profileRow.coachFocusEncrypted !== null,
|
||||
updatedAt: profileRow.updatedAt,
|
||||
}
|
||||
: null,
|
||||
lastCoachUseAt: recentUsage.updatedAt,
|
||||
},
|
||||
now,
|
||||
);
|
||||
if (triggered) return "selfContext";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+94
-11
@@ -4346,19 +4346,25 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/coach/about-me": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Read the caller's \"about me\" self-description",
|
||||
summary: "Read the caller's self-context",
|
||||
description:
|
||||
"v1.15.20 — returns the user-authored free-text self-description the Coach system prompt and the daily briefing inject as a delimited, user-provided context block. Stored encrypted at rest; an undecryptable payload reads as null (fail closed). Auth via cookie or Bearer; the owner is always narrowed from the session.",
|
||||
"v1.16.0 — returns the structured self-context (free text plus chronic conditions, allergies, coach focus) the Coach system prompt and the daily briefing inject as a delimited, user-provided context block, alongside any pending clarifying questions. Every field is stored encrypted at rest; an undecryptable payload reads as null (fail closed). Auth via cookie or Bearer; the owner is always narrowed from the session.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The stored text (null when never written / cleared).",
|
||||
description:
|
||||
"The stored fields (null when never written / cleared) plus pending clarifying questions.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
aboutMe: z.string().nullable(),
|
||||
conditions: z.string().nullable(),
|
||||
allergies: z.string().nullable(),
|
||||
coachFocus: z.string().nullable(),
|
||||
pendingQuestions: z.array(z.string()),
|
||||
updatedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
maxChars: z.number().int(),
|
||||
fieldMaxChars: z.number().int(),
|
||||
}),
|
||||
"GetCoachAboutMeResponse",
|
||||
),
|
||||
@@ -4370,23 +4376,29 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
put: {
|
||||
tags: ["Insights"],
|
||||
summary: "Write (or clear) the caller's \"about me\" self-description",
|
||||
summary: "Write (or clear) the caller's self-context",
|
||||
description:
|
||||
"v1.15.20 — persists the free text encrypted at rest (4 000-char cap enforced before encryption). An empty / whitespace-only value clears the text while keeping the row so `updatedAt` documents the deletion instant. Rate-limited per user.",
|
||||
"v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each) encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying questions (AI when a provider and the daily Coach token budget allow, deterministic completion hints otherwise) and returns them as `pendingQuestions`. Rate-limited per user.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: aboutMePutSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The saved (trimmed) text echoed back.",
|
||||
description:
|
||||
"The effective (trimmed) state echoed back plus the freshly derived pending questions.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
aboutMe: z.string().nullable(),
|
||||
conditions: z.string().nullable(),
|
||||
allergies: z.string().nullable(),
|
||||
coachFocus: z.string().nullable(),
|
||||
pendingQuestions: z.array(z.string()),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
maxChars: z.number().int(),
|
||||
fieldMaxChars: z.number().int(),
|
||||
}),
|
||||
"PutCoachAboutMeResponse",
|
||||
),
|
||||
@@ -4397,12 +4409,69 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/coach/about-me/questions": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Read the pending clarifying questions",
|
||||
description:
|
||||
"v1.16.0 — the up-to-3 clarifying questions derived after the last self-context save. The Coach composer renders them as tappable suggestion chips. Stored encrypted; an undecryptable payload reads as an empty list (fail closed).",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The pending questions (possibly empty).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ questions: z.array(z.string()) }),
|
||||
"GetCoachAboutMeQuestionsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Insights"],
|
||||
summary: "Dismiss pending clarifying questions",
|
||||
description:
|
||||
"v1.16.0 — dismisses one question (body `{ question }`, exact match) or all of them (empty body). Tapping a chip in the Coach composer inserts the question into the chat input and dismisses it here.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
question: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Exact question text to dismiss. Omitted = dismiss all.",
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The remaining questions after the dismissal.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ questions: z.array(z.string()) }),
|
||||
"DeleteCoachAboutMeQuestionsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/admin/invites": {
|
||||
get: {
|
||||
tags: ["Admin"],
|
||||
summary: "List registration invites",
|
||||
description:
|
||||
"v1.15.20 — every invite with creator / consumer usernames and use counters. Metadata only — the raw token is never derivable from this endpoint (only its HMAC hash is persisted). Admin session cookie required; Bearer tokens cannot reach admin endpoints.",
|
||||
"v1.16.0 — every invite with creator / consumer usernames, use counters, soft-revocation timestamp, and the full per-signup redemption ledger (`redemptions`; `consumer` only carries the LAST account on a multi-use invite). Metadata only — the raw token is never derivable from this endpoint (only its HMAC hash is persisted). Admin session cookie required; Bearer tokens cannot reach admin endpoints.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "All invites, newest first.",
|
||||
@@ -4415,6 +4484,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
usedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
revokedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
uses: z.number().int(),
|
||||
maxUses: z.number().int(),
|
||||
creator: z
|
||||
@@ -4423,6 +4493,19 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
consumer: z
|
||||
.object({ id: z.string(), username: z.string() })
|
||||
.nullable(),
|
||||
redemptions: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
redeemedAt: z.iso.datetime({ offset: true }),
|
||||
user: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
email: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
"AdminInviteList",
|
||||
@@ -4480,16 +4563,16 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
summary: "Revoke a registration invite",
|
||||
description:
|
||||
"v1.15.20 — hard-deletes the invite (no plaintext secret, no health data; mint + revoke live in the audit log). Idempotent: an unknown id returns `{ deleted: false }` instead of 404. Admin session cookie required.",
|
||||
"v1.16.0 — soft-revokes the invite (`revokedAt`): the row keeps its redemption history visible in the admin table while the consume path refuses the link like an expired one. Idempotent: an unknown or already-revoked id returns `{ revoked: false }` instead of 404. Admin session cookie required.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The invite was deleted (`deleted: true`) or the id matched nothing (`deleted: false`).",
|
||||
"The invite was revoked (`revoked: true`) or nothing matched (`revoked: false`).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"AdminInviteDeleted",
|
||||
z.object({ revoked: z.boolean() }),
|
||||
"AdminInviteRevoked",
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -395,6 +395,8 @@ export const queryKeys = {
|
||||
coachFacts: () => ["coach-facts"] as const,
|
||||
/** v1.15.20 — Settings → AI "about me" self-description. */
|
||||
coachAboutMe: () => ["coach-about-me"] as const,
|
||||
/** v1.16.0 — pending clarifying questions (Coach composer chips). */
|
||||
coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const,
|
||||
|
||||
/**
|
||||
* v1.4.41 — admin surfaces. Pre-fix every admin section declared its
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* v1.15.20 — request schema for `PUT /api/coach/about-me`.
|
||||
* v1.16.0 — extended with three structured self-context fields
|
||||
* (conditions, allergies, coach focus). Age/gender deliberately stay
|
||||
* on the User profile and are merged into the prompt block at assembly
|
||||
* time — the questionnaire never duplicates them.
|
||||
*
|
||||
* Lives outside the route file so the OpenAPI registry
|
||||
* (`src/lib/openapi/routes.ts`) can import it without touching the
|
||||
@@ -12,6 +16,20 @@ import { z } from "zod/v4";
|
||||
/** Hard cap enforced BEFORE encryption. */
|
||||
export const ABOUT_ME_MAX_CHARS = 4000;
|
||||
|
||||
/** Per-field cap for the structured answers, BEFORE encryption. */
|
||||
export const ABOUT_ME_FIELD_MAX_CHARS = 500;
|
||||
|
||||
const structuredField = z
|
||||
.string()
|
||||
.max(
|
||||
ABOUT_ME_FIELD_MAX_CHARS,
|
||||
`Maximum ${ABOUT_ME_FIELD_MAX_CHARS} characters`,
|
||||
)
|
||||
// Optional so older clients that only send `aboutMe` keep working;
|
||||
// an omitted field leaves the stored value untouched, an empty
|
||||
// string clears it (mirrors the `aboutMe` semantics).
|
||||
.optional();
|
||||
|
||||
export const aboutMePutSchema = z.object({
|
||||
/**
|
||||
* The user's free-text self-description. An empty / whitespace-only
|
||||
@@ -20,6 +38,12 @@ export const aboutMePutSchema = z.object({
|
||||
aboutMe: z
|
||||
.string()
|
||||
.max(ABOUT_ME_MAX_CHARS, `Maximum ${ABOUT_ME_MAX_CHARS} characters`),
|
||||
/** Chronic conditions, short free text. */
|
||||
conditions: structuredField,
|
||||
/** Allergies / intolerances, short free text. */
|
||||
allergies: structuredField,
|
||||
/** What the Coach should pay attention to, short free text. */
|
||||
coachFocus: structuredField,
|
||||
});
|
||||
|
||||
export type AboutMePutInput = z.infer<typeof aboutMePutSchema>;
|
||||
Reference in new issue
Block a user