diff --git a/messages/de.json b/messages/de.json index 378ac8b0e..06d7c9a49 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index f39b413fe..14c8608c5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 6ed2968aa..d3caeac73 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 9554d507d..6a0fd1bce 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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é", diff --git a/messages/it.json b/messages/it.json index 27da1b873..56308a079 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index e10ed0e5d..34daaf1f3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0142_v1160_invite_redemptions/migration.sql b/prisma/migrations/0142_v1160_invite_redemptions/migration.sql new file mode 100644 index 000000000..40902e67c --- /dev/null +++ b/prisma/migrations/0142_v1160_invite_redemptions/migration.sql @@ -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; diff --git a/prisma/migrations/0143_v1160_self_context_fields/migration.sql b/prisma/migrations/0143_v1160_self_context_fields/migration.sql new file mode 100644 index 000000000..6bd6b2ff3 --- /dev/null +++ b/prisma/migrations/0143_v1160_self_context_fields/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3fb6d10a8..47fe79429 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index fec6b7113..f7bb0234e 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -139,12 +139,19 @@ export function AdminSectionRenderer({ title={t("admin.section.users.title")} subtitle={t("admin.section.users.subtitle")} > -
- - {/* v1.15.20 — registration invites live with the accounts - they admit. */} - -
+ + + ); + 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 ( + + ); case "api-tokens": diff --git a/src/app/api/admin/invites/[id]/route.ts b/src/app/api/admin/invites/[id]/route.ts index da5fcbe78..02d73df55 100644 --- a/src/app/api/admin/invites/[id]/route.ts +++ b/src/app/api/admin/invites/[id]/route.ts @@ -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 }); }, ); diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts index 92b6a1829..23df2716d 100644 --- a/src/app/api/admin/invites/route.ts +++ b/src/app/api/admin/invites/route.ts @@ -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" }, }); diff --git a/src/app/api/coach/about-me/questions/route.ts b/src/app/api/coach/about-me/questions/route.ts new file mode 100644 index 000000000..1c4094d21 --- /dev/null +++ b/src/app/api/coach/about-me/questions/route.ts @@ -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"; diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts index 8de3f1ff1..3c2b18bc2 100644 --- a/src/app/api/coach/about-me/route.ts +++ b/src/app/api/coach/about-me/route.ts @@ -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 | null | undefined => { + if (raw === undefined) return undefined; + const value = raw.trim(); + return value.length === 0 ? null : encryptToBytes(value); + }; + const update: { + aboutMeEncrypted: Uint8Array | null; + conditionsEncrypted?: Uint8Array | null; + allergiesEncrypted?: Uint8Array | null; + coachFocusEncrypted?: Uint8Array | 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 = { + 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, }); }); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 05ec7a680..3b77cbfd1 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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 { // 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, diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 03966341e..612f92dff 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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); } diff --git a/src/components/admin/__tests__/invite-status.test.tsx b/src/components/admin/__tests__/invite-status.test.tsx new file mode 100644 index 000000000..b33338aa4 --- /dev/null +++ b/src/components/admin/__tests__/invite-status.test.tsx @@ -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"); + }); +}); diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index a0e092608..a8b34b9a4 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -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", diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx index 88bf5ee8e..501e53d9d 100644 --- a/src/components/admin/invite-tokens-section.tsx +++ b/src/components/admin/invite-tokens-section.tsx @@ -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 = { + // 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 ( + + + ); +} export function InviteTokensSection() { const { t } = useTranslations(); const queryClient = useQueryClient(); + const [createOpen, setCreateOpen] = useState(false); + const [ttlDays, setTtlDays] = useState(7); + const [maxUses, setMaxUses] = useState("1"); const [minted, setMinted] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); + const [copied, setCopied] = useState(false); + const [revokeTarget, setRevokeTarget] = useState(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 => { + mutationFn: async (input: { + expiresInDays: number; + maxUses: number; + }): Promise => { 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; @@ -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 ( -
-
-
-

- {t("admin.invites.description")} -

- -
- - {t("admin.invites.createLabel")} - - {TTL_CHOICES.map((days) => ( - - ))} -
- - {minted && ( -
-

{t("admin.invites.tokenTitle")}

-

- {t("admin.invites.tokenOnceHint")} -

-

- {minted.url} -

-
- {/* eslint-disable-next-line @next/next/no-img-element -- data-URL QR, no remote loader involved */} - {t("admin.invites.qrAlt")} - + {/* Header — copy left, the one action right. */} +
+
+
+
+

+ {t("admin.invites.description")} +

+

+

+
+ +
+ + {isError && ( +

+ {t("admin.invites.loadError")} +

+ )} + + {invites && invites.length === 0 && ( +
+
)} -
- {isError && ( -

- {t("admin.invites.loadError")} -

- )} - {invites && invites.length === 0 && ( -

- {t("admin.invites.listEmpty")} -

- )} - {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 ( -
-
-
- - {statusLabel} - - - {t("admin.invites.expires", { - date: formatDate(invite.expiresAt), - })} - - - {t("admin.invites.uses", { - used: invite.uses, - max: invite.maxUses, - })} - -
-

- {t("admin.invites.createdMeta", { - date: formatDate(invite.createdAt), - by: invite.creator?.username ?? "—", - })} - {invite.consumer - ? ` · ${t("admin.invites.usedBy", { - username: invite.consumer.username, - })}` - : ""} -

-
- -
- ); - })} -
+ {invites && invites.length > 0 && ( + <> + {/* Desktop table (md+) */} +
+ + + + {t("admin.invites.colStatus")} + {t("admin.invites.colCreated")} + {t("admin.invites.colExpires")} + + {t("admin.invites.colUses")} + + {t("admin.invites.colRedeemedBy")} + + + {t("admin.invites.colActions")} + + + + + + {invites.map((invite) => { + const status = deriveInviteStatus(invite, now); + const relative = expiryRelative(invite, status); + return ( + + + + + +
+
+ {formatDate(invite.createdAt)} +
+
+ {t("admin.invites.byCreator", { + by: invite.creator?.username ?? "—", + })} +
+
+
+ +
+
+ {relative !== "—" + ? relative + : formatDate(invite.expiresAt)} +
+ {relative !== "—" && ( +
+ {formatDate(invite.expiresAt)} +
+ )} +
+
+ + {invite.uses} + + {" "} + / {invite.maxUses} + + + + + + + {status === "active" && ( + + )} + +
+ ); + })} +
+
+
- + {invites.map((invite) => { + const status = deriveInviteStatus(invite, now); + const relative = expiryRelative(invite, status); + return ( +
  • +
    + + + {invite.uses} + + {" "} + / {invite.maxUses} + + +
    +
    +
    +
    {t("admin.invites.colCreated")}
    +
    + {formatDate(invite.createdAt)} +
    +
    +
    +
    {t("admin.invites.colExpires")}
    +
    + {relative !== "—" ? `${relative} · ` : ""} + {formatDate(invite.expiresAt)} +
    +
    + {invite.redemptions.length > 0 && ( +
    +
    {t("admin.invites.colRedeemedBy")}
    +
    + {redemptionSummary(invite)} +
    +
    + )} +
    + {status === "active" && ( +
    + +
    + )} +
  • + ); + })} + + + )} + + {/* Create dialog — selection first, then the one-time ticket. */} + { - if (!open) setDeleteTarget(null); + setCreateOpen(open); + if (!open) setMinted(null); + }} + > + + {minted === null ? ( + <> + + {t("admin.invites.createTitle")} + + {t("admin.invites.createDescription")} + + +
    +
    + + {t("admin.invites.ttlLegend")} + +
    + {TTL_CHOICES.map((days) => ( + + ))} +
    +
    +
    + + setMaxUses(e.target.value)} + className="w-24 tabular-nums" + data-testid="admin-invites-max-uses" + /> +

    + {t("admin.invites.maxUsesHint")} +

    +
    +
    + + + + + ) : ( + <> + + {t("admin.invites.tokenTitle")} + + {t("admin.invites.tokenOnceHint")} + + + {/* The ticket — dashed edge marks the one-time nature. */} +
    +
    + {/* eslint-disable-next-line @next/next/no-img-element -- data-URL QR, no remote loader involved */} + {t("admin.invites.qrAlt")} +

    + {minted.url} +

    + +
    +
    +

    + {t("admin.invites.hashExplainer")} +

    + + )} +
    +
    + + {/* Revoke confirmation */} + { + if (!open) setRevokeTarget(null); }} > - {t("admin.invites.deleteConfirmTitle")} + {t("admin.invites.revokeConfirmTitle")} - {t("admin.invites.deleteConfirmBody")} + {t("admin.invites.revokeConfirmBody")} {t("admin.invites.cancel")} { - if (deleteTarget) remove.mutate(deleteTarget.id); - setDeleteTarget(null); + if (revokeTarget) revoke.mutate(revokeTarget.id); + setRevokeTarget(null); }} > - {t("admin.invites.deleteConfirmAction")} + {t("admin.invites.revokeConfirmAction")} -
    + + ); +} + +/** + * "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 —; + } + if (invite.redemptions.length === 1) { + const entry = invite.redemptions[0]; + return ( +
    +
    + {entry.user?.username ?? t("admin.invites.redeemerDeleted")} +
    +
    + {formatDateTime(entry.redeemedAt)} +
    +
    + ); + } + return ( + + + + + +

    + {t("admin.invites.redemptionsTitle", { + count: invite.redemptions.length, + })} +

    +
      + {invite.redemptions.map((entry) => ( +
    • +
      + {entry.user?.username ?? t("admin.invites.redeemerDeleted")} + {entry.user?.email && ( + + {" "} + · {entry.user.email} + + )} +
      +
      + {formatDateTime(entry.redeemedAt)} +
      +
    • + ))} +
    +
    +
    ); } diff --git a/src/components/admin/section-slugs.ts b/src/components/admin/section-slugs.ts index d5a20e925..8581cd072 100644 --- a/src/components/admin/section-slugs.ts +++ b/src/components/admin/section-slugs.ts @@ -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", diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx index 83505500b..05bce9538 100644 --- a/src/components/settings/about-me-section.tsx +++ b/src/components/settings/about-me-section.tsx @@ -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 - * `