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:
Marc-André Bombeck committed 2026-06-10 16:18:48 +02:00
1 parent 9fe5673251
commit 5b75184649
32 files changed
+2648 -423

No files matched your search

+57 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+13 -6
View File
@@ -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":
+18 -12
View File
@@ -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 });
},
);
+11
View File
@@ -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";
+130 -49
View File
@@ -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,
});
});
+4 -2
View File
@@ -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,
+2 -2
View File
@@ -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");
});
});
+8
View File
@@ -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",
+614 -163
View File
@@ -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>
);
}
+5
View File
@@ -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",
+231 -46
View File
@@ -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
View File
@@ -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.`;
}
+163
View File
@@ -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();
}
}
+131
View File
@@ -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);
});
});
+30 -10
View File
@@ -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`.
}
}
+2 -2
View File
@@ -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);
}
+70 -2
View File
@@ -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);
});
});
+98 -1
View File
@@ -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
View File
@@ -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",
),
},
},
+2
View File
@@ -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
+24
View File
@@ -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>;