mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
chore: remove the bug-report integration
Drop the in-app bug-report surface end to end: the user submission page, the feedback intake and admin inbox, the optional GitHub-issue escalation, the operator config, and the dedicated achievement badges. - delete the /bugreport page and the bugreport / feedback / admin-feedback API routes - drop the Feedback model, its enums, and the AppSettings GitHub-issue and enable columns (migration 0205) - remove the admin Feedback section, the Bug Report integration card, and the system-status row - drop the bug-report nav entry and the app-settings provider that only carried its flag - remove the bug-hunter achievement badges and their metric - prune the related i18n keys across every locale and the encrypted-column and key-rotation registries
This commit is contained in:
1 parent
6d053c3667
commit
de33f1ad4c
68 files changed
+115
-3344
No files matched your search
+2
-2
@@ -41,8 +41,8 @@ intentional:
|
||||
- **`tags`** — domain groupings (`Auth`, `Measurements`, `Medications`,
|
||||
`Mood`, `Dashboard`, `Insights`, `Achievements`, `DoctorReport`,
|
||||
`Integrations`, `User`, `Tokens`, `Notifications`, `Onboarding`,
|
||||
`Health`, `Settings`, `Analytics`, `Export`, `AuditLog`, `Feedback`,
|
||||
`BugReport`, `Monitoring`, `Webhooks`, `Ingest`, `Admin`).
|
||||
`Health`, `Settings`, `Analytics`, `Export`, `AuditLog`,
|
||||
`Monitoring`, `Webhooks`, `Ingest`, `Admin`).
|
||||
- **`securitySchemes`**:
|
||||
- `bearerAuth` — long-lived `hlk_*` API tokens issued via `POST /api/tokens`.
|
||||
Required on `/api/ingest/medication`.
|
||||
|
||||
+2
-87
@@ -32,7 +32,6 @@
|
||||
"copy": "Kopieren",
|
||||
"copyDetails": "Details kopieren",
|
||||
"retry": "Erneut versuchen",
|
||||
"reportIssue": "Fehler melden",
|
||||
"showPassword": "Passwort anzeigen",
|
||||
"hidePassword": "Passwort verbergen",
|
||||
"learnMore": "Mehr erfahren"
|
||||
@@ -204,7 +203,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Erfolge",
|
||||
"settings": "Einstellungen",
|
||||
"bugreport": "Bugreport",
|
||||
"admin": "Admin-Konsole",
|
||||
"logout": "Abmelden",
|
||||
"login": "Anmelden",
|
||||
@@ -5064,7 +5062,6 @@
|
||||
"webPushGlobal": "Browser-Push-Benachrichtigungen erlauben.",
|
||||
"moodLogGlobal": "moodLog-Erinnerungen erlauben.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug-Reports",
|
||||
"offlineGeoLabel": "Geodaten",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — MAXMIND_LICENSE_KEY in CI setzen",
|
||||
@@ -5101,12 +5098,6 @@
|
||||
"configured": "Konfiguriert",
|
||||
"monitoringTestSuccess": "Testevent wurde gesendet",
|
||||
"monitoringTestFailed": "Monitoring-Test fehlgeschlagen",
|
||||
"bugReportGithub": "Bugreport (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.",
|
||||
"bugReportEnabledLabel": "Bug-Reports aktiviert",
|
||||
"bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.",
|
||||
"bugReportDeprecatedBadge": "Veraltet",
|
||||
"bugReportDeprecatedNote": "Diese Funktion ist veraltet und wird in einer künftigen Version entfernt.",
|
||||
"assistant": {
|
||||
"title": "Assistenz-Oberflächen",
|
||||
"description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen. Der Hauptschalter erzwingt jede Unter-Option aus.",
|
||||
@@ -5140,10 +5131,6 @@
|
||||
"description": "Schalte ein Modul für den gesamten Server ab. Ein hier deaktiviertes Modul ist für jedes Konto aus – unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten.",
|
||||
"saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden."
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue-Token",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Einladungen",
|
||||
"description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung. Der Link wird nur einmal angezeigt.",
|
||||
@@ -5297,47 +5284,6 @@
|
||||
"reminderCheckNotifOff": "Benachrichtigungen aus",
|
||||
"reminderCheckEventsToday": "Heutige Events",
|
||||
"reminderCheckNotifSent": "Gesendet",
|
||||
"feedback": {
|
||||
"tabOpen": "Offen",
|
||||
"tabAcknowledged": "Bestätigt",
|
||||
"tabResolved": "Erledigt",
|
||||
"tabArchived": "Archiviert",
|
||||
"subject": "Betreff",
|
||||
"category": "Kategorie",
|
||||
"user": "Benutzer",
|
||||
"createdAt": "Eingegangen",
|
||||
"actions": "Aktionen",
|
||||
"details": "Details",
|
||||
"emptyTitle": "Alles erledigt",
|
||||
"emptyDescription": "Kein Nutzer-Feedback in diesem Status. Bug-Reports, Fragen und Feature-Wünsche erscheinen hier.",
|
||||
"loading": "Lade Feedback...",
|
||||
"anonymous": "anonym",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Feature",
|
||||
"categoryQuestion": "Frage",
|
||||
"categoryOther": "Sonstiges",
|
||||
"metadataHeading": "Metadaten",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Sprache",
|
||||
"metaUserAgent": "User-Agent",
|
||||
"metaAppVersion": "App-Version",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Admin-Notiz",
|
||||
"adminNotePlaceholder": "Interne Notiz (nicht für Benutzer sichtbar)",
|
||||
"saveNote": "Notiz speichern",
|
||||
"noteSaved": "Notiz gespeichert",
|
||||
"actionAcknowledge": "Bestätigen",
|
||||
"actionResolve": "Erledigen",
|
||||
"actionArchive": "Archivieren",
|
||||
"actionPublishGithub": "Auf GitHub veröffentlichen",
|
||||
"publishedToGithub": "Auf GitHub veröffentlicht",
|
||||
"viewIssue": "Issue ansehen",
|
||||
"publishSuccess": "Issue erstellt",
|
||||
"publishFailed": "GitHub-Veröffentlichung fehlgeschlagen",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen",
|
||||
"archiveConfirm": "Diesen Feedback-Eintrag archivieren?",
|
||||
"submittedBy": "Eingereicht von"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Übersicht",
|
||||
"sectionsNav": "Admin-Bereiche"
|
||||
@@ -5390,10 +5336,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Assistenz-Oberflächen, Server-Schlüssel sowie die Feedback-Qualitäts-Tabellen für Coach und Auswertungen an einem Ort."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"subtitle": "Feedback, Bug-Reports und Feature-Wünsche der Nutzer sichten."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Erinnerungen",
|
||||
"subtitle": "Schwellwerte für Medikamentenerinnerungen anpassen."
|
||||
@@ -5583,10 +5525,6 @@
|
||||
"title": "Oldschool",
|
||||
"description": "Melde dich mindestens einmal mit Username und Passwort an."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Fehlerjäger",
|
||||
"description": "Reiche einen Bugreport ein."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Login-Serie 7T",
|
||||
"description": "Logge dich an 7 Tagen in Folge ein."
|
||||
@@ -5775,10 +5713,6 @@
|
||||
"title": "Polyglott",
|
||||
"description": "Du hast die App-Sprache mindestens einmal gewechselt."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Bug-Kumpel",
|
||||
"description": "Mindestens 5 Bugreports eingereicht — das Projekt liebt dich."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Lückenlos · 7T",
|
||||
"description": "7 Tage in Folge ohne eine verpasste Dosis."
|
||||
@@ -5831,26 +5765,6 @@
|
||||
"empty": "Noch keine Erfolge — trage weiter ein, um deinen ersten freizuschalten."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Bugreport",
|
||||
"subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.",
|
||||
"loginRequired": "Bitte anmelden, um einen Bug zu melden.",
|
||||
"bugTitle": "Titel",
|
||||
"description": "Beschreibung",
|
||||
"submit": "Senden",
|
||||
"success": "Vielen Dank! Dein Bug Report wurde erfolgreich übermittelt.",
|
||||
"errorCreating": "Fehler beim Erstellen",
|
||||
"category": "Art",
|
||||
"categoryBug": "Fehlermeldung",
|
||||
"categoryFeature": "Feature-Wunsch",
|
||||
"categoryQuestion": "Frage",
|
||||
"categoryOther": "Sonstiges Feedback",
|
||||
"bugTitlePlaceholder": "Kurze Zusammenfassung (optional)",
|
||||
"githubEscalationNote": "Dein Feedback wird lokal gespeichert. Wenn der Administrator die GitHub-Integration aktiviert, kann es auf Wunsch als öffentliches Issue veröffentlicht werden.",
|
||||
"internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar.",
|
||||
"disabledTitle": "Bug-Reports sind deaktiviert",
|
||||
"disabledBody": "Der Administrator hat Bug-Reports auf diesem Server deaktiviert."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"eventColumn": "Ereignis",
|
||||
@@ -5955,7 +5869,8 @@
|
||||
"eventMeasurementReminder": "Mess-Erinnerung",
|
||||
"eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.",
|
||||
"eventMedicationIntakeSync": "Einnahme-Synchronisierung",
|
||||
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird."
|
||||
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.",
|
||||
"loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+2
-87
@@ -32,7 +32,6 @@
|
||||
"copy": "Copy",
|
||||
"copyDetails": "Copy details",
|
||||
"retry": "Retry",
|
||||
"reportIssue": "Report issue",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"learnMore": "Learn more"
|
||||
@@ -204,7 +203,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Achievements",
|
||||
"settings": "Settings",
|
||||
"bugreport": "Bug Report",
|
||||
"admin": "Admin Console",
|
||||
"logout": "Sign out",
|
||||
"login": "Sign in",
|
||||
@@ -5064,7 +5062,6 @@
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Geo data",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — set MAXMIND_LICENSE_KEY in CI",
|
||||
@@ -5101,12 +5098,6 @@
|
||||
"configured": "Configured",
|
||||
"monitoringTestSuccess": "Test event sent",
|
||||
"monitoringTestFailed": "Monitoring test failed",
|
||||
"bugReportGithub": "Bug report (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
|
||||
"bugReportEnabledLabel": "Bug reports enabled",
|
||||
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
|
||||
"bugReportDeprecatedBadge": "Deprecated",
|
||||
"bugReportDeprecatedNote": "This feature is deprecated and will be removed in a future version.",
|
||||
"assistant": {
|
||||
"title": "Assistant Surfaces",
|
||||
"description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration. The master kills every sub-flag at once.",
|
||||
@@ -5140,10 +5131,6 @@
|
||||
"description": "Turn a module off for the whole server. A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled.",
|
||||
"saveError": "Could not update module availability."
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue token",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Invites",
|
||||
"description": "Registration invite links — they work even while registration is closed. The link is shown only once.",
|
||||
@@ -5297,47 +5284,6 @@
|
||||
"reminderCheckNotifOff": "Notifications off",
|
||||
"reminderCheckEventsToday": "Events today",
|
||||
"reminderCheckNotifSent": "Sent",
|
||||
"feedback": {
|
||||
"tabOpen": "Open",
|
||||
"tabAcknowledged": "Acknowledged",
|
||||
"tabResolved": "Resolved",
|
||||
"tabArchived": "Archived",
|
||||
"subject": "Subject",
|
||||
"category": "Category",
|
||||
"user": "User",
|
||||
"createdAt": "Submitted",
|
||||
"actions": "Actions",
|
||||
"details": "Details",
|
||||
"emptyTitle": "All caught up",
|
||||
"emptyDescription": "No user feedback in this status. Submitted bugs, questions, and feature requests will appear here.",
|
||||
"loading": "Loading feedback...",
|
||||
"anonymous": "anonymous",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Feature",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Other",
|
||||
"metadataHeading": "Metadata",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Locale",
|
||||
"metaUserAgent": "User agent",
|
||||
"metaAppVersion": "App version",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Admin note",
|
||||
"adminNotePlaceholder": "Internal note (not visible to user)",
|
||||
"saveNote": "Save note",
|
||||
"noteSaved": "Note saved",
|
||||
"actionAcknowledge": "Acknowledge",
|
||||
"actionResolve": "Resolve",
|
||||
"actionArchive": "Archive",
|
||||
"actionPublishGithub": "Publish to GitHub",
|
||||
"publishedToGithub": "Published to GitHub",
|
||||
"viewIssue": "View issue",
|
||||
"publishSuccess": "Issue created",
|
||||
"publishFailed": "GitHub publish failed",
|
||||
"updateFailed": "Update failed",
|
||||
"archiveConfirm": "Archive this feedback entry?",
|
||||
"submittedBy": "Submitted by"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Overview",
|
||||
"sectionsNav": "Admin sections"
|
||||
@@ -5390,10 +5336,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Assistant surfaces, server key, and the Coach and Insights feedback-quality tables in one place."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"subtitle": "Triage user feedback, bug reports, and feature requests."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Reminders",
|
||||
"subtitle": "Tune medication reminder thresholds and run a manual check."
|
||||
@@ -5583,10 +5525,6 @@
|
||||
"title": "Old school",
|
||||
"description": "Sign in at least once with username and password."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Bug hunter",
|
||||
"description": "Submit a bug report."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Login streak 7d",
|
||||
"description": "Sign in on 7 consecutive days."
|
||||
@@ -5775,10 +5713,6 @@
|
||||
"title": "Polyglot",
|
||||
"description": "Switched the app language at least once."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Bug buddy",
|
||||
"description": "Submitted at least 5 bug reports — the project loves you."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "No dose missed · 7d",
|
||||
"description": "7 consecutive days without a missed dose."
|
||||
@@ -5831,26 +5765,6 @@
|
||||
"empty": "No achievements yet — keep logging to unlock your first."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Bug Report",
|
||||
"subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.",
|
||||
"loginRequired": "Please sign in to report a bug.",
|
||||
"bugTitle": "Title",
|
||||
"description": "Description",
|
||||
"submit": "Send",
|
||||
"success": "Thank you! Your bug report has been submitted successfully.",
|
||||
"errorCreating": "Error creating report",
|
||||
"category": "Type",
|
||||
"categoryBug": "Bug report",
|
||||
"categoryFeature": "Feature request",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Other feedback",
|
||||
"bugTitlePlaceholder": "Short summary (optional)",
|
||||
"githubEscalationNote": "Your feedback is stored locally. If the admin enables GitHub integration, it can be promoted to a public issue on request.",
|
||||
"internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server.",
|
||||
"disabledTitle": "Bug reports are turned off",
|
||||
"disabledBody": "The administrator has disabled bug reports on this server."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"eventColumn": "Event",
|
||||
@@ -5955,7 +5869,8 @@
|
||||
"eventMeasurementReminder": "Measurement reminder",
|
||||
"eventMeasurementReminderDesc": "Sent when a tracked measurement is due.",
|
||||
"eventMedicationIntakeSync": "Medication intake sync",
|
||||
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped."
|
||||
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.",
|
||||
"loginRequired": "Please sign in to manage notifications."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+2
-87
@@ -32,7 +32,6 @@
|
||||
"copy": "Copiar",
|
||||
"copyDetails": "Copiar detalles",
|
||||
"retry": "Reintentar",
|
||||
"reportIssue": "Reportar problema",
|
||||
"showPassword": "Mostrar contraseña",
|
||||
"hidePassword": "Ocultar contraseña",
|
||||
"learnMore": "Más información"
|
||||
@@ -204,7 +203,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Logros",
|
||||
"settings": "Ajustes",
|
||||
"bugreport": "Reporte de bug",
|
||||
"admin": "Consola de administración",
|
||||
"logout": "Cerrar sesión",
|
||||
"login": "Iniciar sesión",
|
||||
@@ -5064,7 +5062,6 @@
|
||||
"webPushGlobal": "Permitir notificaciones push del navegador.",
|
||||
"moodLogGlobal": "Permitir recordatorios del registro de ánimo.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Informe de errores",
|
||||
"offlineGeoLabel": "Datos geográficos",
|
||||
"offlineGeoEnabled": "Sin conexión (GeoLite2)",
|
||||
"offlineGeoFallback": "Respaldo (ipwho.is) — configura MAXMIND_LICENSE_KEY en CI",
|
||||
@@ -5101,12 +5098,6 @@
|
||||
"configured": "Configurado",
|
||||
"monitoringTestSuccess": "Evento de prueba enviado",
|
||||
"monitoringTestFailed": "La prueba de monitorización falló",
|
||||
"bugReportGithub": "Informe de errores (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Configura el token de issues de GitHub y el repositorio para los informes de errores entrantes.",
|
||||
"bugReportEnabledLabel": "Informes de errores activados",
|
||||
"bugReportEnabledDescription": "Cuando está desactivado, el botón de informe en la app se oculta; el token y el repositorio permanecen configurados.",
|
||||
"bugReportDeprecatedBadge": "Obsoleto",
|
||||
"bugReportDeprecatedNote": "Esta función está obsoleta y se eliminará en una versión futura.",
|
||||
"assistant": {
|
||||
"title": "Superficies del asistente",
|
||||
"description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor. El interruptor maestro fuerza cada opción secundaria a off.",
|
||||
@@ -5140,10 +5131,6 @@
|
||||
"description": "Desactiva un módulo para todo el servidor. Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar.",
|
||||
"saveError": "No se pudo actualizar la disponibilidad de módulos."
|
||||
},
|
||||
"bugReportRepo": "Repositorio",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Token de issues",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Invitaciones",
|
||||
"description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado. El enlace se muestra solo una vez.",
|
||||
@@ -5297,47 +5284,6 @@
|
||||
"reminderCheckNotifOff": "Notificaciones desactivadas",
|
||||
"reminderCheckEventsToday": "Eventos de hoy",
|
||||
"reminderCheckNotifSent": "Enviadas",
|
||||
"feedback": {
|
||||
"tabOpen": "Abierto",
|
||||
"tabAcknowledged": "Confirmado",
|
||||
"tabResolved": "Resuelto",
|
||||
"tabArchived": "Archivado",
|
||||
"subject": "Asunto",
|
||||
"category": "Categoría",
|
||||
"user": "Usuario",
|
||||
"createdAt": "Enviado",
|
||||
"actions": "Acciones",
|
||||
"details": "Detalles",
|
||||
"emptyTitle": "Todo al día",
|
||||
"emptyDescription": "No hay comentarios de usuarios en este estado. Los errores, preguntas y solicitudes de funciones enviados aparecerán aquí.",
|
||||
"loading": "Cargando comentarios...",
|
||||
"anonymous": "anónimo",
|
||||
"categoryBug": "Error",
|
||||
"categoryFeature": "Función",
|
||||
"categoryQuestion": "Pregunta",
|
||||
"categoryOther": "Otro",
|
||||
"metadataHeading": "Metadatos",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Idioma",
|
||||
"metaUserAgent": "Agente de usuario",
|
||||
"metaAppVersion": "Versión de la app",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Nota de administración",
|
||||
"adminNotePlaceholder": "Nota interna (no visible para el usuario)",
|
||||
"saveNote": "Guardar nota",
|
||||
"noteSaved": "Nota guardada",
|
||||
"actionAcknowledge": "Confirmar",
|
||||
"actionResolve": "Resolver",
|
||||
"actionArchive": "Archivar",
|
||||
"actionPublishGithub": "Publicar en GitHub",
|
||||
"publishedToGithub": "Publicado en GitHub",
|
||||
"viewIssue": "Ver issue",
|
||||
"publishSuccess": "Issue creada",
|
||||
"publishFailed": "La publicación en GitHub falló",
|
||||
"updateFailed": "La actualización falló",
|
||||
"archiveConfirm": "¿Archivar esta entrada de comentarios?",
|
||||
"submittedBy": "Enviado por"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Resumen",
|
||||
"sectionsNav": "Secciones de administración"
|
||||
@@ -5390,10 +5336,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Superficies del asistente, clave del servidor y las tablas de calidad de comentarios del Coach y de los análisis, en un solo lugar."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Comentarios",
|
||||
"subtitle": "Clasifica los comentarios de usuarios, los informes de errores y las solicitudes de funciones."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Recordatorios",
|
||||
"subtitle": "Ajusta los umbrales de los recordatorios de medicación y ejecuta una comprobación manual."
|
||||
@@ -5583,10 +5525,6 @@
|
||||
"title": "A la antigua",
|
||||
"description": "Inicia sesión al menos una vez con usuario y contraseña."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Cazador de errores",
|
||||
"description": "Envía un informe de error."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Racha de inicios 7d",
|
||||
"description": "Inicia sesión 7 días seguidos."
|
||||
@@ -5775,10 +5713,6 @@
|
||||
"title": "Políglota",
|
||||
"description": "Cambiaste el idioma de la app al menos una vez."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Colega de errores",
|
||||
"description": "Enviaste al menos 5 informes de error — el proyecto te quiere."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Sin dosis perdidas · 7d",
|
||||
"description": "7 días consecutivos sin una dosis perdida."
|
||||
@@ -5831,26 +5765,6 @@
|
||||
"empty": "Aún no hay logros — sigue registrando para desbloquear el primero."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Reporte de bug",
|
||||
"subtitle": "Envía un fallo, una sugerencia o una pregunta. El administrador del servidor verá tu envío.",
|
||||
"loginRequired": "Inicia sesión para reportar un bug.",
|
||||
"bugTitle": "Título",
|
||||
"description": "Descripción",
|
||||
"submit": "Enviar",
|
||||
"success": "¡Gracias! Tu reporte se ha enviado correctamente.",
|
||||
"errorCreating": "Error al crear",
|
||||
"category": "Tipo",
|
||||
"categoryBug": "Fallo",
|
||||
"categoryFeature": "Sugerencia",
|
||||
"categoryQuestion": "Pregunta",
|
||||
"categoryOther": "Otro feedback",
|
||||
"bugTitlePlaceholder": "Resumen breve (opcional)",
|
||||
"githubEscalationNote": "Tu feedback se guarda localmente. Si el administrador activa la integración con GitHub, puede publicarse como issue público a petición.",
|
||||
"internalOnlyNote": "Tu feedback se guarda localmente y solo es visible para el administrador de este servidor.",
|
||||
"disabledTitle": "Los informes de errores están desactivados",
|
||||
"disabledBody": "El administrador ha desactivado los informes de errores en este servidor."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"eventColumn": "Evento",
|
||||
@@ -5955,7 +5869,8 @@
|
||||
"eventMeasurementReminder": "Recordatorio de medición",
|
||||
"eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.",
|
||||
"eventMedicationIntakeSync": "Sincronización de tomas",
|
||||
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis."
|
||||
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.",
|
||||
"loginRequired": "Inicia sesión para gestionar las notificaciones."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+2
-87
@@ -32,7 +32,6 @@
|
||||
"copy": "Copier",
|
||||
"copyDetails": "Copier les détails",
|
||||
"retry": "Réessayer",
|
||||
"reportIssue": "Signaler un problème",
|
||||
"showPassword": "Afficher le mot de passe",
|
||||
"hidePassword": "Masquer le mot de passe",
|
||||
"learnMore": "En savoir plus"
|
||||
@@ -204,7 +203,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Succès",
|
||||
"settings": "Paramètres",
|
||||
"bugreport": "Rapport de bug",
|
||||
"admin": "Console d’administration",
|
||||
"logout": "Se déconnecter",
|
||||
"login": "Se connecter",
|
||||
@@ -5064,7 +5062,6 @@
|
||||
"webPushGlobal": "Autoriser les notifications push du navigateur.",
|
||||
"moodLogGlobal": "Autoriser les rappels du journal d'humeur.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Rapport de bug",
|
||||
"offlineGeoLabel": "Données géographiques",
|
||||
"offlineGeoEnabled": "Hors ligne (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — définissez MAXMIND_LICENSE_KEY dans la CI",
|
||||
@@ -5101,12 +5098,6 @@
|
||||
"configured": "Configuré",
|
||||
"monitoringTestSuccess": "Événement de test envoyé",
|
||||
"monitoringTestFailed": "Échec du test de surveillance",
|
||||
"bugReportGithub": "Rapport de bug (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Configurez le jeton d'issues GitHub et le dépôt pour les rapports de bug entrants.",
|
||||
"bugReportEnabledLabel": "Rapports de bug activés",
|
||||
"bugReportEnabledDescription": "Lorsque cette option est désactivée, le bouton de signalement dans l'app est masqué — le jeton et le dépôt restent configurés.",
|
||||
"bugReportDeprecatedBadge": "Obsolète",
|
||||
"bugReportDeprecatedNote": "Cette fonctionnalité est obsolète et sera supprimée dans une version future.",
|
||||
"assistant": {
|
||||
"title": "Surfaces d'assistance",
|
||||
"description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur. Le commutateur principal force chaque option secondaire à off.",
|
||||
@@ -5140,10 +5131,6 @@
|
||||
"description": "Désactivez un module pour tout le serveur. Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés.",
|
||||
"saveError": "Impossible de mettre à jour la disponibilité des modules."
|
||||
},
|
||||
"bugReportRepo": "Dépôt",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Jeton d'issue",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"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.",
|
||||
@@ -5297,47 +5284,6 @@
|
||||
"reminderCheckNotifOff": "Notifications désactivées",
|
||||
"reminderCheckEventsToday": "Événements aujourd'hui",
|
||||
"reminderCheckNotifSent": "Envoyées",
|
||||
"feedback": {
|
||||
"tabOpen": "Ouvert",
|
||||
"tabAcknowledged": "Pris en compte",
|
||||
"tabResolved": "Résolu",
|
||||
"tabArchived": "Archivé",
|
||||
"subject": "Sujet",
|
||||
"category": "Catégorie",
|
||||
"user": "Utilisateur",
|
||||
"createdAt": "Soumis",
|
||||
"actions": "Actions",
|
||||
"details": "Détails",
|
||||
"emptyTitle": "Tout est à jour",
|
||||
"emptyDescription": "Aucun retour utilisateur dans ce statut. Les bugs, questions et demandes de fonctionnalités soumis apparaîtront ici.",
|
||||
"loading": "Chargement des retours...",
|
||||
"anonymous": "anonyme",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Fonctionnalité",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Autre",
|
||||
"metadataHeading": "Métadonnées",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Langue",
|
||||
"metaUserAgent": "Agent utilisateur",
|
||||
"metaAppVersion": "Version de l'app",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Note d'administration",
|
||||
"adminNotePlaceholder": "Note interne (non visible par l'utilisateur)",
|
||||
"saveNote": "Enregistrer la note",
|
||||
"noteSaved": "Note enregistrée",
|
||||
"actionAcknowledge": "Prendre en compte",
|
||||
"actionResolve": "Résoudre",
|
||||
"actionArchive": "Archiver",
|
||||
"actionPublishGithub": "Publier sur GitHub",
|
||||
"publishedToGithub": "Publié sur GitHub",
|
||||
"viewIssue": "Voir l'issue",
|
||||
"publishSuccess": "Issue créée",
|
||||
"publishFailed": "Échec de la publication sur GitHub",
|
||||
"updateFailed": "Échec de la mise à jour",
|
||||
"archiveConfirm": "Archiver cette entrée de retour ?",
|
||||
"submittedBy": "Soumis par"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Vue d'ensemble",
|
||||
"sectionsNav": "Sections d'administration"
|
||||
@@ -5390,10 +5336,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Surfaces d'assistance, clé du serveur et les tableaux de qualité des retours du Coach et des analyses, au même endroit."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Retours",
|
||||
"subtitle": "Triez les retours utilisateurs, les rapports de bug et les demandes de fonctionnalités."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Rappels",
|
||||
"subtitle": "Réglez les seuils des rappels de médicaments et lancez une vérification manuelle."
|
||||
@@ -5583,10 +5525,6 @@
|
||||
"title": "À l’ancienne",
|
||||
"description": "Connectez-vous au moins une fois avec un identifiant et un mot de passe."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Chasseur de bugs",
|
||||
"description": "Soumettez un rapport de bug."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Série de connexions 7j",
|
||||
"description": "Connectez-vous 7 jours d’affilée."
|
||||
@@ -5775,10 +5713,6 @@
|
||||
"title": "Polyglotte",
|
||||
"description": "Vous avez changé la langue de l’application au moins une fois."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Compagnon des bugs",
|
||||
"description": "Vous avez soumis au moins 5 rapports de bug — le projet vous adore."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Aucune dose oubliée · 7j",
|
||||
"description": "7 jours consécutifs sans dose oubliée."
|
||||
@@ -5831,26 +5765,6 @@
|
||||
"empty": "Pas encore de réussites — continuez à suivre pour débloquer la première."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Rapport de bug",
|
||||
"subtitle": "Envoie un bug, une suggestion ou une question. L’administrateur du serveur verra ton envoi.",
|
||||
"loginRequired": "Connecte-toi pour signaler un bug.",
|
||||
"bugTitle": "Titre",
|
||||
"description": "Description",
|
||||
"submit": "Envoyer",
|
||||
"success": "Merci ! Ton rapport a bien été envoyé.",
|
||||
"errorCreating": "Erreur lors de la création",
|
||||
"category": "Type",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Suggestion",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Autre retour",
|
||||
"bugTitlePlaceholder": "Résumé court (optionnel)",
|
||||
"githubEscalationNote": "Ton retour est stocké localement. Si l’administrateur active l’intégration GitHub, il pourra être publié comme issue public à la demande.",
|
||||
"internalOnlyNote": "Ton retour est stocké localement et n’est visible que par l’administrateur de ce serveur.",
|
||||
"disabledTitle": "Les rapports de bug sont désactivés",
|
||||
"disabledBody": "L'administrateur a désactivé les rapports de bug sur ce serveur."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"eventColumn": "Événement",
|
||||
@@ -5955,7 +5869,8 @@
|
||||
"eventMeasurementReminder": "Rappel de mesure",
|
||||
"eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.",
|
||||
"eventMedicationIntakeSync": "Synchronisation des prises",
|
||||
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée."
|
||||
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.",
|
||||
"loginRequired": "Connecte-toi pour gérer les notifications."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+2
-87
@@ -32,7 +32,6 @@
|
||||
"copy": "Copia",
|
||||
"copyDetails": "Copia dettagli",
|
||||
"retry": "Riprova",
|
||||
"reportIssue": "Segnala un problema",
|
||||
"showPassword": "Mostra password",
|
||||
"hidePassword": "Nascondi password",
|
||||
"learnMore": "Scopri di più"
|
||||
@@ -204,7 +203,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Traguardi",
|
||||
"settings": "Impostazioni",
|
||||
"bugreport": "Segnalazione bug",
|
||||
"admin": "Console di amministrazione",
|
||||
"logout": "Esci",
|
||||
"login": "Accedi",
|
||||
@@ -5064,7 +5062,6 @@
|
||||
"webPushGlobal": "Consentire le notifiche push del browser.",
|
||||
"moodLogGlobal": "Consentire i promemoria del diario dell'umore.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Segnalazione di bug",
|
||||
"offlineGeoLabel": "Dati geografici",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — imposta MAXMIND_LICENSE_KEY in CI",
|
||||
@@ -5101,12 +5098,6 @@
|
||||
"configured": "Configurato",
|
||||
"monitoringTestSuccess": "Evento di prova inviato",
|
||||
"monitoringTestFailed": "Test di monitoraggio non riuscito",
|
||||
"bugReportGithub": "Segnalazione di bug (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Configura il token delle issue di GitHub e il repository per le segnalazioni di bug in arrivo.",
|
||||
"bugReportEnabledLabel": "Segnalazioni di bug attive",
|
||||
"bugReportEnabledDescription": "Se disattivato, il pulsante di segnalazione nell'app viene nascosto: token e repository restano configurati.",
|
||||
"bugReportDeprecatedBadge": "Obsoleto",
|
||||
"bugReportDeprecatedNote": "Questa funzione è obsoleta e verrà rimossa in una versione futura.",
|
||||
"assistant": {
|
||||
"title": "Superfici dell'assistente",
|
||||
"description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider. Il master forza ogni opzione secondaria su off.",
|
||||
@@ -5140,10 +5131,6 @@
|
||||
"description": "Disattiva un modulo per l'intero server. Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati.",
|
||||
"saveError": "Impossibile aggiornare la disponibilità dei moduli."
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Token delle issue",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Inviti",
|
||||
"description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa. Il link viene mostrato una sola volta.",
|
||||
@@ -5297,47 +5284,6 @@
|
||||
"reminderCheckNotifOff": "Notifiche disattivate",
|
||||
"reminderCheckEventsToday": "Eventi di oggi",
|
||||
"reminderCheckNotifSent": "Inviate",
|
||||
"feedback": {
|
||||
"tabOpen": "Aperto",
|
||||
"tabAcknowledged": "Preso in carico",
|
||||
"tabResolved": "Risolto",
|
||||
"tabArchived": "Archiviato",
|
||||
"subject": "Oggetto",
|
||||
"category": "Categoria",
|
||||
"user": "Utente",
|
||||
"createdAt": "Inviato",
|
||||
"actions": "Azioni",
|
||||
"details": "Dettagli",
|
||||
"emptyTitle": "Tutto in ordine",
|
||||
"emptyDescription": "Nessun feedback degli utenti in questo stato. Bug, domande e richieste di funzionalità inviati compariranno qui.",
|
||||
"loading": "Caricamento feedback...",
|
||||
"anonymous": "anonimo",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Funzionalità",
|
||||
"categoryQuestion": "Domanda",
|
||||
"categoryOther": "Altro",
|
||||
"metadataHeading": "Metadati",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Lingua",
|
||||
"metaUserAgent": "User agent",
|
||||
"metaAppVersion": "Versione dell'app",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Nota amministrativa",
|
||||
"adminNotePlaceholder": "Nota interna (non visibile all'utente)",
|
||||
"saveNote": "Salva nota",
|
||||
"noteSaved": "Nota salvata",
|
||||
"actionAcknowledge": "Prendi in carico",
|
||||
"actionResolve": "Risolvi",
|
||||
"actionArchive": "Archivia",
|
||||
"actionPublishGithub": "Pubblica su GitHub",
|
||||
"publishedToGithub": "Pubblicato su GitHub",
|
||||
"viewIssue": "Vedi issue",
|
||||
"publishSuccess": "Issue creata",
|
||||
"publishFailed": "Pubblicazione su GitHub non riuscita",
|
||||
"updateFailed": "Aggiornamento non riuscito",
|
||||
"archiveConfirm": "Archiviare questa voce di feedback?",
|
||||
"submittedBy": "Inviato da"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Panoramica",
|
||||
"sectionsNav": "Sezioni di amministrazione"
|
||||
@@ -5390,10 +5336,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Superfici dell'assistente, chiave del server e le tabelle di qualità del feedback di Coach e analisi, in un unico posto."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"subtitle": "Smista i feedback degli utenti, le segnalazioni di bug e le richieste di funzionalità."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Promemoria",
|
||||
"subtitle": "Regola le soglie dei promemoria dei farmaci ed esegui un controllo manuale."
|
||||
@@ -5583,10 +5525,6 @@
|
||||
"title": "Vecchia scuola",
|
||||
"description": "Accedi almeno una volta con nome utente e password."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Cacciatore di bug",
|
||||
"description": "Invia una segnalazione di bug."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Serie di accessi 7g",
|
||||
"description": "Accedi per 7 giorni consecutivi."
|
||||
@@ -5775,10 +5713,6 @@
|
||||
"title": "Poliglotta",
|
||||
"description": "Hai cambiato la lingua dell’app almeno una volta."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Amico dei bug",
|
||||
"description": "Hai inviato almeno 5 segnalazioni di bug — il progetto ti ama."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Nessuna dose persa · 7g",
|
||||
"description": "7 giorni consecutivi senza una dose persa."
|
||||
@@ -5831,26 +5765,6 @@
|
||||
"empty": "Ancora nessun obiettivo — continua a registrare per sbloccare il primo."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Segnalazione bug",
|
||||
"subtitle": "Invia un bug, un’idea o una domanda. L’amministratore del server vedrà la tua segnalazione.",
|
||||
"loginRequired": "Accedi per segnalare un bug.",
|
||||
"bugTitle": "Titolo",
|
||||
"description": "Descrizione",
|
||||
"submit": "Invia",
|
||||
"success": "Grazie! La tua segnalazione è stata inviata.",
|
||||
"errorCreating": "Errore durante la creazione",
|
||||
"category": "Tipo",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Suggerimento",
|
||||
"categoryQuestion": "Domanda",
|
||||
"categoryOther": "Altro feedback",
|
||||
"bugTitlePlaceholder": "Riassunto breve (opzionale)",
|
||||
"githubEscalationNote": "Il feedback viene salvato localmente. Se l’amministratore attiva l’integrazione GitHub, può essere pubblicato come issue pubblico su richiesta.",
|
||||
"internalOnlyNote": "Il feedback viene salvato localmente ed è visibile solo all’amministratore di questo server.",
|
||||
"disabledTitle": "Le segnalazioni di bug sono disattivate",
|
||||
"disabledBody": "L'amministratore ha disattivato le segnalazioni di bug su questo server."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifiche",
|
||||
"eventColumn": "Evento",
|
||||
@@ -5955,7 +5869,8 @@
|
||||
"eventMeasurementReminder": "Promemoria misurazione",
|
||||
"eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.",
|
||||
"eventMedicationIntakeSync": "Sincronizzazione assunzioni",
|
||||
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata."
|
||||
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.",
|
||||
"loginRequired": "Accedi per gestire le notifiche."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+2
-87
@@ -32,7 +32,6 @@
|
||||
"copy": "Kopiuj",
|
||||
"copyDetails": "Kopiuj szczegóły",
|
||||
"retry": "Spróbuj ponownie",
|
||||
"reportIssue": "Zgłoś problem",
|
||||
"showPassword": "Pokaż hasło",
|
||||
"hidePassword": "Ukryj hasło",
|
||||
"learnMore": "Dowiedz się więcej"
|
||||
@@ -204,7 +203,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Osiągnięcia",
|
||||
"settings": "Ustawienia",
|
||||
"bugreport": "Zgłoszenie błędu",
|
||||
"admin": "Konsola administratora",
|
||||
"logout": "Wyloguj",
|
||||
"login": "Zaloguj",
|
||||
@@ -5064,7 +5062,6 @@
|
||||
"webPushGlobal": "Zezwól na powiadomienia push przeglądarki.",
|
||||
"moodLogGlobal": "Zezwól na przypomnienia dziennika nastroju.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Zgłoszenie błędu",
|
||||
"offlineGeoLabel": "Dane geograficzne",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Awaryjne (ipwho.is) — ustaw MAXMIND_LICENSE_KEY w CI",
|
||||
@@ -5101,12 +5098,6 @@
|
||||
"configured": "Skonfigurowano",
|
||||
"monitoringTestSuccess": "Wysłano zdarzenie testowe",
|
||||
"monitoringTestFailed": "Test monitorowania nie powiódł się",
|
||||
"bugReportGithub": "Zgłoszenie błędu (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Skonfiguruj token zgłoszeń GitHub i repozytorium dla przychodzących zgłoszeń błędów.",
|
||||
"bugReportEnabledLabel": "Zgłaszanie błędów włączone",
|
||||
"bugReportEnabledDescription": "Gdy wyłączone, przycisk zgłaszania w aplikacji jest ukryty — token i repozytorium pozostają skonfigurowane.",
|
||||
"bugReportDeprecatedBadge": "Wycofane",
|
||||
"bugReportDeprecatedNote": "Ta funkcja jest wycofywana i zostanie usunięta w przyszłej wersji.",
|
||||
"assistant": {
|
||||
"title": "Powierzchnie asystenta",
|
||||
"description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy. Główny wyłącznik wymusza wyłączenie każdej opcji podrzędnej.",
|
||||
@@ -5140,10 +5131,6 @@
|
||||
"description": "Wyłącz moduł dla całego serwera. Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć.",
|
||||
"saveError": "Nie udało się zaktualizować dostępności modułów."
|
||||
},
|
||||
"bugReportRepo": "Repozytorium",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Token zgłoszeń",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Zaproszenia",
|
||||
"description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji. Link jest wyświetlany tylko raz.",
|
||||
@@ -5297,47 +5284,6 @@
|
||||
"reminderCheckNotifOff": "Powiadomienia wyłączone",
|
||||
"reminderCheckEventsToday": "Zdarzenia dzisiaj",
|
||||
"reminderCheckNotifSent": "Wysłane",
|
||||
"feedback": {
|
||||
"tabOpen": "Otwarte",
|
||||
"tabAcknowledged": "Potwierdzone",
|
||||
"tabResolved": "Rozwiązane",
|
||||
"tabArchived": "Zarchiwizowane",
|
||||
"subject": "Temat",
|
||||
"category": "Kategoria",
|
||||
"user": "Użytkownik",
|
||||
"createdAt": "Przesłano",
|
||||
"actions": "Akcje",
|
||||
"details": "Szczegóły",
|
||||
"emptyTitle": "Wszystko na bieżąco",
|
||||
"emptyDescription": "Brak opinii użytkowników w tym statusie. Przesłane błędy, pytania i prośby o funkcje pojawią się tutaj.",
|
||||
"loading": "Ładowanie opinii...",
|
||||
"anonymous": "anonimowy",
|
||||
"categoryBug": "Błąd",
|
||||
"categoryFeature": "Funkcja",
|
||||
"categoryQuestion": "Pytanie",
|
||||
"categoryOther": "Inne",
|
||||
"metadataHeading": "Metadane",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Język",
|
||||
"metaUserAgent": "Agent użytkownika",
|
||||
"metaAppVersion": "Wersja aplikacji",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Notatka administratora",
|
||||
"adminNotePlaceholder": "Notatka wewnętrzna (niewidoczna dla użytkownika)",
|
||||
"saveNote": "Zapisz notatkę",
|
||||
"noteSaved": "Notatka zapisana",
|
||||
"actionAcknowledge": "Potwierdź",
|
||||
"actionResolve": "Rozwiąż",
|
||||
"actionArchive": "Archiwizuj",
|
||||
"actionPublishGithub": "Opublikuj na GitHub",
|
||||
"publishedToGithub": "Opublikowano na GitHub",
|
||||
"viewIssue": "Zobacz zgłoszenie",
|
||||
"publishSuccess": "Utworzono zgłoszenie",
|
||||
"publishFailed": "Publikacja na GitHub nie powiodła się",
|
||||
"updateFailed": "Aktualizacja nie powiodła się",
|
||||
"archiveConfirm": "Zarchiwizować ten wpis opinii?",
|
||||
"submittedBy": "Przesłane przez"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Przegląd",
|
||||
"sectionsNav": "Sekcje administracyjne"
|
||||
@@ -5390,10 +5336,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Powierzchnie asystenta, klucz serwera oraz tabele jakości opinii Coacha i analiz w jednym miejscu."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Opinie",
|
||||
"subtitle": "Segreguj opinie użytkowników, zgłoszenia błędów i prośby o funkcje."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Przypomnienia",
|
||||
"subtitle": "Dostrój progi przypomnień o lekach i uruchom ręczne sprawdzenie."
|
||||
@@ -5583,10 +5525,6 @@
|
||||
"title": "Stara szkoła",
|
||||
"description": "Zaloguj się co najmniej raz nazwą użytkownika i hasłem."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Łowca błędów",
|
||||
"description": "Wyślij zgłoszenie błędu."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Seria logowań 7d",
|
||||
"description": "Zaloguj się przez 7 kolejnych dni."
|
||||
@@ -5775,10 +5713,6 @@
|
||||
"title": "Poliglota",
|
||||
"description": "Zmieniłeś język aplikacji co najmniej raz."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Towarzysz błędów",
|
||||
"description": "Wysłałeś co najmniej 5 zgłoszeń błędów — projekt Cię kocha."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Bez pominiętej dawki · 7 dni",
|
||||
"description": "7 kolejnych dni bez pominiętej dawki."
|
||||
@@ -5831,26 +5765,6 @@
|
||||
"empty": "Brak osiągnięć — zapisuj dalej, aby odblokować pierwsze."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Zgłoszenie błędu",
|
||||
"subtitle": "Wyślij zgłoszenie błędu, sugestię funkcji lub pytanie. Administrator serwera zobaczy twoje zgłoszenie.",
|
||||
"loginRequired": "Zaloguj się, aby zgłosić błąd.",
|
||||
"bugTitle": "Tytuł",
|
||||
"description": "Opis",
|
||||
"submit": "Wyślij",
|
||||
"success": "Dziękujemy! Twoje zgłoszenie zostało wysłane.",
|
||||
"errorCreating": "Błąd tworzenia",
|
||||
"category": "Rodzaj",
|
||||
"categoryBug": "Błąd",
|
||||
"categoryFeature": "Propozycja funkcji",
|
||||
"categoryQuestion": "Pytanie",
|
||||
"categoryOther": "Inna opinia",
|
||||
"bugTitlePlaceholder": "Krótkie podsumowanie (opcjonalnie)",
|
||||
"githubEscalationNote": "Twoja opinia zapisuje się lokalnie. Jeśli administrator włączy integrację z GitHubem, może zostać opublikowana jako publiczne issue na życzenie.",
|
||||
"internalOnlyNote": "Twoja opinia zapisuje się lokalnie i jest widoczna tylko dla administratora tego serwera.",
|
||||
"disabledTitle": "Zgłoszenia błędów są wyłączone",
|
||||
"disabledBody": "Administrator wyłączył zgłoszenia błędów na tym serwerze."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
"eventColumn": "Zdarzenie",
|
||||
@@ -5955,7 +5869,8 @@
|
||||
"eventMeasurementReminder": "Przypomnienie o pomiarze",
|
||||
"eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.",
|
||||
"eventMedicationIntakeSync": "Synchronizacja przyjęć leku",
|
||||
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta."
|
||||
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.",
|
||||
"loginRequired": "Zaloguj się, aby zarządzać powiadomieniami."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- v1.23 — remove the bug-report integration.
|
||||
--
|
||||
-- The in-app bug-report / feedback surface (user form, admin inbox, optional
|
||||
-- GitHub-issue escalation) is retired. This migration drops the dedicated
|
||||
-- feedback table, its enums, and the three AppSettings columns that held the
|
||||
-- GitHub-issue credentials and the enable toggle.
|
||||
--
|
||||
-- Destructive by design: the feature is gone, so its stored data goes with it.
|
||||
DROP TABLE IF EXISTS "feedback";
|
||||
|
||||
DROP TYPE IF EXISTS "feedback_status";
|
||||
DROP TYPE IF EXISTS "feedback_category";
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
DROP COLUMN IF EXISTS "github_issue_token_encrypted",
|
||||
DROP COLUMN IF EXISTS "github_issue_repo",
|
||||
DROP COLUMN IF EXISTS "bug_report_enabled";
|
||||
@@ -606,7 +606,6 @@ model User {
|
||||
pushSubscriptions PushSubscription[]
|
||||
dataBackups DataBackup[]
|
||||
achievements UserAchievement[]
|
||||
feedback Feedback[]
|
||||
recommendationFeedback RecommendationFeedback[]
|
||||
idempotencyKeys IdempotencyKey[]
|
||||
devices Device[]
|
||||
@@ -3204,13 +3203,6 @@ model AppSettings {
|
||||
glitchtipEnabled Boolean @default(false) @map("glitchtip_enabled")
|
||||
glitchtipDsn String? @map("glitchtip_dsn")
|
||||
glitchtipEnvironment String? @map("glitchtip_environment")
|
||||
githubIssueTokenEncrypted String? @map("github_issue_token_encrypted")
|
||||
githubIssueRepo String? @map("github_issue_repo")
|
||||
// v1.4.3: explicit toggle so an admin can hide the bug-report
|
||||
// surface without nuking the token+repo config. Default true so
|
||||
// existing instances with credentials don't accidentally lose
|
||||
// the feature on upgrade.
|
||||
bugReportEnabled Boolean @default(true) @map("bug_report_enabled")
|
||||
|
||||
// Medication reminder thresholds (minutes after windowEnd)
|
||||
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
|
||||
@@ -3281,52 +3273,6 @@ model AppSettings {
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
// ─── Built-in Feedback ───────────────────────────────────────
|
||||
|
||||
enum FeedbackCategory {
|
||||
BUG
|
||||
FEATURE_REQUEST
|
||||
QUESTION
|
||||
OTHER
|
||||
|
||||
@@map("feedback_category")
|
||||
}
|
||||
|
||||
enum FeedbackStatus {
|
||||
OPEN
|
||||
ACKNOWLEDGED
|
||||
RESOLVED
|
||||
ARCHIVED
|
||||
|
||||
@@map("feedback_status")
|
||||
}
|
||||
|
||||
/// Stores user-submitted feedback without requiring external GitHub config.
|
||||
/// Admin inbox lives at /admin#feedback; optional escalation publishes to a
|
||||
/// GitHub issue reusing the existing integration when configured.
|
||||
model Feedback {
|
||||
id String @id @default(cuid())
|
||||
userId String? @map("user_id") // Null after user deletion; email snapshot remains.
|
||||
email String? // Snapshot at submission time for triage continuity.
|
||||
category FeedbackCategory @default(BUG)
|
||||
subject String
|
||||
description String
|
||||
screenshotBase64 String? @map("screenshot_base64") // Optional, <5MB, PNG/JPG.
|
||||
metadata Json? // URL, userAgent, locale, appVersion, …
|
||||
status FeedbackStatus @default(OPEN)
|
||||
adminNote String? @map("admin_note")
|
||||
gitHubIssueUrl String? @map("github_issue_url")
|
||||
notifiedEmail Boolean @default(false) @map("notified_email")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([userId])
|
||||
@@map("feedback")
|
||||
}
|
||||
|
||||
// ─── Audit Log ─────────────────────────────────────────────
|
||||
|
||||
model AuditLog {
|
||||
|
||||
@@ -227,11 +227,9 @@ async function main() {
|
||||
|
||||
// ───── AppSettings — operator credentials (String) ─────
|
||||
// Columns: "adminAiKeyEncrypted" "webPushVapidPrivateKeyEncrypted"
|
||||
// "githubIssueTokenEncrypted"
|
||||
for (const field of [
|
||||
"adminAiKeyEncrypted",
|
||||
"webPushVapidPrivateKeyEncrypted",
|
||||
"githubIssueTokenEncrypted",
|
||||
]) {
|
||||
results.push(
|
||||
await rotateStringColumn("AppSettings", field, prisma.appSettings),
|
||||
|
||||
@@ -137,7 +137,6 @@ const mockData = {
|
||||
passkeyLoginCount: 0,
|
||||
passwordLoginCount: 0,
|
||||
loginDayStreak: 0,
|
||||
bugReportCount: 0,
|
||||
moodEntryCount: 0,
|
||||
moodDayStreak: 0,
|
||||
moodImprovementHit: 0,
|
||||
|
||||
@@ -25,7 +25,6 @@ import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-s
|
||||
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
|
||||
import { BackupsSection } from "@/components/admin/backups-section";
|
||||
import { DangerZoneSection } from "@/components/admin/danger-zone-section";
|
||||
import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section";
|
||||
import { GeneralSettingsSection } from "@/components/admin/general-settings-section";
|
||||
import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section";
|
||||
import { LoginOverviewSection } from "@/components/admin/login-overview-section";
|
||||
@@ -120,15 +119,6 @@ export function AdminSectionRenderer({
|
||||
<ModuleAvailabilitySection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "feedback":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.feedback.title")}
|
||||
subtitle={t("admin.section.feedback.subtitle")}
|
||||
>
|
||||
<FeedbackInboxSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "reminders":
|
||||
return (
|
||||
<SectionFrame
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PATCH /api/admin/feedback/[id].
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
feedback: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_ADMIN = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
|
||||
};
|
||||
|
||||
function patchReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/feedback/f1", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const ROUTE_CTX = { params: Promise.resolve({ id: "f1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_ADMIN as never);
|
||||
});
|
||||
|
||||
describe("PATCH /api/admin/feedback/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `status` enum + adminNote too long.
|
||||
const res = await PATCH(
|
||||
patchReq({ status: "junk", adminNote: "x".repeat(10000) }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
|
||||
// The schema has only two knobs (status + adminNote), so the strict
|
||||
// 3-issue ceiling isn't reachable. Multi-issue contract is pinned at
|
||||
// ≥ 2 by the case above.
|
||||
const res = await PATCH(
|
||||
patchReq({ status: 999, adminNote: 123 }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* POST /api/admin/feedback/[id]/github — promote feedback to a GitHub Issue.
|
||||
*
|
||||
* Reuses publishFeedbackToGithub() so the issue body matches the legacy
|
||||
* /api/bugreport flow. Idempotent: if gitHubIssueUrl is already set we return
|
||||
* the existing URL instead of creating a duplicate.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
publishFeedbackToGithub,
|
||||
GithubPublishError,
|
||||
} from "@/lib/feedback/publish-github";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const feedback = await prisma.feedback.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
gitHubIssueUrl: true,
|
||||
screenshotBase64: true,
|
||||
metadata: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
if (!feedback) return apiError("Feedback not found", 404);
|
||||
|
||||
if (feedback.gitHubIssueUrl) {
|
||||
return apiError(`Already published: ${feedback.gitHubIssueUrl}`, 409);
|
||||
}
|
||||
|
||||
// Atomic claim to prevent concurrent admin clicks from creating two
|
||||
// duplicate GitHub issues. Only the first caller whose updateMany
|
||||
// matches (gitHubIssueUrl still null) proceeds. We write a PENDING
|
||||
// sentinel now and replace it with the real URL after the API call.
|
||||
const PENDING = "pending://creating";
|
||||
const claim = await prisma.feedback.updateMany({
|
||||
where: { id, gitHubIssueUrl: null },
|
||||
data: { gitHubIssueUrl: PENDING },
|
||||
});
|
||||
if (claim.count !== 1) {
|
||||
return apiError("Already being published by another request", 409);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishFeedbackToGithub({
|
||||
category: feedback.category,
|
||||
subject: feedback.subject,
|
||||
description: feedback.description,
|
||||
username: feedback.user?.username ?? "anonymous",
|
||||
metadata: feedback.metadata,
|
||||
});
|
||||
|
||||
await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { gitHubIssueUrl: result.issueUrl },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.github_publish", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id, issueNumber: result.issueNumber },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.github_publish" },
|
||||
meta: { feedback_id: id, issue_number: result.issueNumber },
|
||||
});
|
||||
|
||||
return apiSuccess({ issueUrl: result.issueUrl });
|
||||
} catch (err) {
|
||||
// Release the PENDING claim so a retry can succeed.
|
||||
await prisma.feedback
|
||||
.updateMany({
|
||||
where: { id, gitHubIssueUrl: PENDING },
|
||||
data: { gitHubIssueUrl: null },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (err instanceof GithubPublishError) {
|
||||
return apiError(err.message, err.status);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* PATCH /api/admin/feedback/[id] — change status or admin note.
|
||||
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { updateFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const before = await prisma.feedback.findUnique({ where: { id } });
|
||||
if (!before) return apiError("Feedback not found", 404);
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: id,
|
||||
before: { status: before.status, adminNote: before.adminNote },
|
||||
after: parsed.data,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.update" },
|
||||
meta: { feedback_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { status: "ARCHIVED" },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.archive", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Admin feedback inbox. Lists all feedback with status filter.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { FeedbackStatus } from "@/generated/prisma/client";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const status = url.searchParams.get("status") as FeedbackStatus | null;
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 50)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const where = status ? { status } : {};
|
||||
|
||||
const [items, total, counts] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
email: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
metadata: true,
|
||||
screenshotBase64: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where }),
|
||||
prisma.feedback.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.list" },
|
||||
meta: { total, filtered_status: status },
|
||||
});
|
||||
|
||||
const countsByStatus = Object.fromEntries(
|
||||
counts.map((c) => [c.status, c._count._all]),
|
||||
) as Record<FeedbackStatus, number>;
|
||||
|
||||
return apiSuccess({
|
||||
items,
|
||||
meta: { total, limit, offset, countsByStatus },
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,6 @@ describe("GET /api/admin/settings", () => {
|
||||
expect(body.data.registrationEnabled).toBe(true);
|
||||
expect(body.data.defaultLocale).toBe("de");
|
||||
expect(body.data.webPushVapidConfigured).toBe(false);
|
||||
expect(body.data.bugReportConfigured).toBe(false);
|
||||
expect(body.data.glitchtipEnabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -120,8 +119,6 @@ describe("GET /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -129,15 +126,12 @@ describe("GET /api/admin/settings", () => {
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
// The two configured-flags must be true …
|
||||
// The configured flag must be true …
|
||||
expect(body.data.webPushVapidConfigured).toBe(true);
|
||||
expect(body.data.bugReportConfigured).toBe(true);
|
||||
// … but raw encrypted blobs must never reach the client.
|
||||
const serialized = JSON.stringify(body.data);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted");
|
||||
expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,8 +179,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -227,8 +219,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -257,8 +247,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -292,8 +280,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
moodLogGlobal: false,
|
||||
@@ -317,8 +303,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
bugReportRepo: "owner/repo",
|
||||
bugReportToken: "gh-token",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
}),
|
||||
@@ -336,8 +320,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
@@ -345,7 +327,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
reminderMissedMinutes: 90,
|
||||
});
|
||||
expect(encrypt).toHaveBeenCalledWith("secret");
|
||||
expect(encrypt).toHaveBeenCalledWith("gh-token");
|
||||
});
|
||||
|
||||
it("clears nullable string fields when an empty string is submitted", async () => {
|
||||
@@ -366,8 +347,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -381,7 +360,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: "",
|
||||
glitchtipDsn: "",
|
||||
glitchtipEnvironment: "",
|
||||
bugReportRepo: "",
|
||||
}),
|
||||
);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
@@ -392,39 +370,9 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: null,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the encrypted bug-report token when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
await PUT(jsonReq({ clearBugReportToken: true }));
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
@@ -454,9 +402,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
bugReportEnabled: true,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -486,9 +431,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
bugReportEnabled: true,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
invalidateServerDefaultTimezone,
|
||||
isValidTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import { invalidateAppSettings } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -48,11 +47,6 @@ export const GET = apiHandler(async () => {
|
||||
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
|
||||
glitchtipDsn: settings?.glitchtipDsn ?? null,
|
||||
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
|
||||
bugReportRepo: settings?.githubIssueRepo ?? null,
|
||||
bugReportConfigured: Boolean(
|
||||
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
|
||||
),
|
||||
bugReportEnabled: settings?.bugReportEnabled ?? true,
|
||||
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
|
||||
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
|
||||
moodLogGlobal: settings?.moodLogGlobal ?? true,
|
||||
@@ -92,7 +86,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
"umamiEnabled",
|
||||
"glitchtipEnabled",
|
||||
"moodLogGlobal",
|
||||
"bugReportEnabled",
|
||||
] as const;
|
||||
for (const field of booleanFields) {
|
||||
if (data[field] !== undefined) {
|
||||
@@ -171,24 +164,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
auditDetails.glitchtipEnvironment = value || null;
|
||||
}
|
||||
|
||||
// Bug report
|
||||
if (data.bugReportRepo !== undefined) {
|
||||
const repo = data.bugReportRepo.trim();
|
||||
updates.githubIssueRepo = repo || null;
|
||||
auditDetails.bugReportRepo = repo || null;
|
||||
}
|
||||
if (data.bugReportToken !== undefined) {
|
||||
const token = data.bugReportToken.trim();
|
||||
if (token) {
|
||||
updates.githubIssueTokenEncrypted = encrypt(token);
|
||||
auditDetails.bugReportTokenUpdated = true;
|
||||
}
|
||||
}
|
||||
if (data.clearBugReportToken === true) {
|
||||
updates.githubIssueTokenEncrypted = null;
|
||||
auditDetails.bugReportTokenUpdated = false;
|
||||
}
|
||||
|
||||
// Numeric thresholds
|
||||
if (data.reminderLateMinutes !== undefined) {
|
||||
updates.reminderLateMinutes = data.reminderLateMinutes;
|
||||
@@ -233,11 +208,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
invalidateServerDefaultTimezone();
|
||||
}
|
||||
|
||||
// v1.4.34 IW-G — bust the bug-report status cache (global singleton)
|
||||
// so the next read reflects the new GitHub-token / bug-report-enabled
|
||||
// shape. Cheap call — the cache holds at most 10 entries.
|
||||
invalidateAppSettings();
|
||||
|
||||
await auditLog("admin.settings.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -264,11 +234,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
glitchtipEnabled: settings.glitchtipEnabled,
|
||||
glitchtipDsn: settings.glitchtipDsn,
|
||||
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
|
||||
bugReportRepo: settings.githubIssueRepo,
|
||||
bugReportConfigured: Boolean(
|
||||
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
|
||||
),
|
||||
bugReportEnabled: settings.bugReportEnabled,
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { invalidateAppSettings } from "@/lib/cache/invalidate";
|
||||
import { z } from "zod/v4";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -114,8 +113,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
invalidateAppSettings();
|
||||
|
||||
await auditLog("admin.web_push_vapid.generate", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -89,11 +89,10 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
umami: null,
|
||||
glitchtip: null,
|
||||
webPush: null,
|
||||
bugReport: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
umamiScriptUrl: "https://x/script.js",
|
||||
umamiWebsiteId: "site-1",
|
||||
@@ -103,8 +102,6 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
@@ -121,7 +118,6 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
enabled: false,
|
||||
});
|
||||
expect(body.data.integrations.webPush).toEqual({ configured: true });
|
||||
expect(body.data.integrations.bugReport).toEqual({ configured: true });
|
||||
|
||||
// Regression: encrypted blobs MUST never appear in the response envelope.
|
||||
const serialized = JSON.stringify(body);
|
||||
|
||||
@@ -56,10 +56,6 @@ export const GET = apiHandler(async () => {
|
||||
appSettings?.webPushVapidPrivateKeyEncrypted &&
|
||||
appSettings?.webPushVapidSubject,
|
||||
);
|
||||
const bugReportConfigured = Boolean(
|
||||
appSettings?.githubIssueRepo && appSettings?.githubIssueTokenEncrypted,
|
||||
);
|
||||
|
||||
const workerStatus = getWorkerStatus();
|
||||
|
||||
return apiSuccess({
|
||||
@@ -89,7 +85,6 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
: null,
|
||||
webPush: webPushConfigured ? { configured: true } : null,
|
||||
bugReport: bugReportConfigured ? { configured: true } : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/bugreport.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (s: string) => s.replace(/^enc-/, ""),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/bugreport", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
githubIssueTokenEncrypted: "enc-token",
|
||||
githubIssueRepo: "owner/repo",
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// description too short + screenshot doesn't match data-URL regex.
|
||||
const res = await POST(
|
||||
postReq({ description: "tiny", screenshot: "not-a-data-url" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
|
||||
// Schema has only two knobs (description, screenshot). Pin contract
|
||||
// on ≥ 2.
|
||||
const res = await POST(postReq({ description: 123, screenshot: 999 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Strict image/* data URL (no SVG — it can carry inline JS).
|
||||
const SCREENSHOT_DATA_URL =
|
||||
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
|
||||
|
||||
const bugReportSchema = z.object({
|
||||
description: z.string().min(10, "Description too short").max(5000),
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot too large (max 5 MB)")
|
||||
.regex(
|
||||
SCREENSHOT_DATA_URL,
|
||||
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 3 bug reports per hour", 429);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "bugreport.submit" } });
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.3: explicit toggle. When the admin disables bug reports we
|
||||
// return 503 immediately — the client already renders a "not
|
||||
// configured" UI for that status, so the report button gracefully
|
||||
// disappears without needing additional client-side wiring.
|
||||
if (appSettings && appSettings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
let configuredToken: string | null = null;
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
|
||||
} catch {
|
||||
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
|
||||
}
|
||||
}
|
||||
|
||||
const ghToken = configuredToken || process.env.GITHUB_ISSUE_TOKEN;
|
||||
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
|
||||
|
||||
if (!ghToken || !ghRepo) {
|
||||
// 503 instead of 500: the client keys off status to render the "not
|
||||
// configured" screen. The user also sees a clear message rather than
|
||||
// "Internal server error".
|
||||
return apiError(
|
||||
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = bugReportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { description, screenshot } = parsed.data;
|
||||
|
||||
// GitHub issues are maintainer-facing and the repo's primary language is English.
|
||||
// Use an ISO-8601 timestamp so titles are unambiguous and sortable.
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
const title = `Bug Report – ${dateStr}`;
|
||||
|
||||
// Sanitize user-provided content for GitHub markdown. Strip HTML tags
|
||||
// entirely (prevents `<img onerror=>` etc.) and neutralise markdown link
|
||||
// targets that start with dangerous schemes like javascript: or data:.
|
||||
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
|
||||
const safeDescription = description
|
||||
.replace(/<[^>]*>/g, "") // strip HTML tags
|
||||
.replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes
|
||||
.replace(/```/g, "\\`\\`\\`")
|
||||
.slice(0, 5000);
|
||||
|
||||
// Wrap the user's description in a fenced code block so any residual
|
||||
// markdown renders literally rather than as executable markup.
|
||||
let issueBody = `**Reported by:** ${safeUsername}\n`;
|
||||
issueBody += `**Date:** ${dateStr}\n\n`;
|
||||
issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`;
|
||||
issueBody += `\n---\n*Created via HealthLog bug report*`;
|
||||
|
||||
// If screenshot provided, upload to GitHub as a gist or include as base64
|
||||
// For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64
|
||||
if (screenshot) {
|
||||
issueBody += `\n\n**Screenshot:** A screenshot was attached (see comment).`;
|
||||
}
|
||||
|
||||
// Create the issue
|
||||
const issueRes = await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: `[Bug] ${title}`,
|
||||
body: issueBody,
|
||||
labels: ["bug", "user-reported"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!issueRes.ok) {
|
||||
const errBody = await issueRes.text();
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + errBody);
|
||||
return apiError("Failed to create issue", 500);
|
||||
}
|
||||
|
||||
const issue = (await issueRes.json()) as {
|
||||
number: number;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
// If screenshot, upload as a comment
|
||||
if (screenshot) {
|
||||
// Extract just the base64 part
|
||||
const base64Data = screenshot.replace(/^data:image\/\w+;base64,/, "");
|
||||
const ext = screenshot.startsWith("data:image/png") ? "png" : "jpg";
|
||||
|
||||
// Upload screenshot as issue comment with embedded image
|
||||
// GitHub doesn't support base64 in comments, so we note the limitation
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
|
||||
|
||||
await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({ body: commentBody }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("bugreport.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
issueNumber: issue.number,
|
||||
hasScreenshot: Boolean(screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ meta: { issue_number: issue.number } });
|
||||
|
||||
return apiSuccess({
|
||||
issueNumber: issue.number,
|
||||
issueUrl: issue.html_url,
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Returns whether bug report submission is configured (GitHub token + repo).
|
||||
*
|
||||
* The bug report page uses this to gate its UI so users don't hit a silent
|
||||
* 500 when the admin hasn't set up the GitHub integration. Only two booleans
|
||||
* escape: a single `configured` flag and `isAdmin` so the page can render
|
||||
* the admin-specific copy + admin link. Token/repo granularity is kept
|
||||
* server-side (log annotation only) so we don't hand regular users a probe.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
|
||||
interface BugreportStatusCacheValue {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
hasToken: boolean;
|
||||
hasRepo: boolean;
|
||||
}
|
||||
|
||||
async function buildBugreportStatusValue(): Promise<BugreportStatusCacheValue> {
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hasToken = Boolean(
|
||||
appSettings?.githubIssueTokenEncrypted || process.env.GITHUB_ISSUE_TOKEN,
|
||||
);
|
||||
const hasRepo = Boolean(
|
||||
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO,
|
||||
);
|
||||
const configured = hasToken && hasRepo;
|
||||
// Default ON when the column has never been written.
|
||||
const enabled = appSettings?.bugReportEnabled !== false;
|
||||
|
||||
return { configured, enabled, hasToken, hasRepo };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// No per-request rate limit: the payload rides the singleton server
|
||||
// cache below, so a cache hit costs zero Postgres round-trips — while
|
||||
// the former rate-limit bucket was an unconditional Postgres UPSERT
|
||||
// per request, i.e. the limiter WAS the per-request DB load it claimed
|
||||
// to prevent. The route stays authenticated; the only uncached work is
|
||||
// one `appSettings` read per 10 min (the bucket's TTL) process-wide.
|
||||
|
||||
// Cache the global app-settings shape on a singleton key (per blueprint §3).
|
||||
// `isAdmin` lives outside the cache because it varies per request. The
|
||||
// `invalidateAppSettings()` helper called from
|
||||
// `PATCH /api/admin/app-settings` evicts this entry when bug-report
|
||||
// configuration changes.
|
||||
const status = await cached(
|
||||
caches.bugreportStatus as ServerCache<BugreportStatusCacheValue>,
|
||||
"singleton",
|
||||
() => buildBugreportStatusValue(),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "bugreport.status" },
|
||||
meta: {
|
||||
configured: status.configured,
|
||||
enabled: status.enabled,
|
||||
has_token: status.hasToken,
|
||||
has_repo: status.hasRepo,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
configured: status.configured,
|
||||
enabled: status.enabled,
|
||||
isAdmin: user.role === "ADMIN",
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1", email: "test@example.com", role: "USER" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
feedback: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
function jsonRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/feedback bug-report toggle gate", () => {
|
||||
it("returns 503 when bugReportEnabled is false", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: false,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/disabled/i);
|
||||
expect(prisma.feedback.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates feedback when bugReportEnabled is true", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.feedback.create).mockResolvedValue({
|
||||
id: "fb-1",
|
||||
createdAt: new Date(),
|
||||
category: "BUG",
|
||||
status: "NEW",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates feedback when bugReportEnabled has never been written (default ON)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.feedback.create).mockResolvedValue({
|
||||
id: "fb-2",
|
||||
createdAt: new Date(),
|
||||
category: "BUG",
|
||||
status: "NEW",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/feedback — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
// subject too short + description too short.
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "x",
|
||||
description: "short",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
// bad subject + bad description + bad screenshot.
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "x",
|
||||
description: "short",
|
||||
screenshot: "not-a-data-url",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* POST /api/feedback — user submits feedback. Always works, no GitHub needed.
|
||||
* GET /api/feedback — user's own feedback history (paginated).
|
||||
*
|
||||
* Rate-limited at 5 per hour per user. Screenshots are stored as base64 in
|
||||
* the database (≤5 MB each).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`feedback:${user.id}`, 5, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many submissions — please try again later", 429);
|
||||
}
|
||||
|
||||
// The admin "Bug reports" toggle gates *every* feedback submission, not
|
||||
// just the GitHub-promotion path. Previously the gate only lived in the
|
||||
// legacy `/api/bugreport` route which the form does not even call —
|
||||
// so flipping the toggle off had no effect on user-visible behaviour.
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { bugReportEnabled: true },
|
||||
});
|
||||
if (settings && settings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const feedback = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
email: user.email ?? null,
|
||||
category: parsed.data.category,
|
||||
subject: parsed.data.subject,
|
||||
description: parsed.data.description,
|
||||
screenshotBase64: parsed.data.screenshot ?? null,
|
||||
metadata:
|
||||
parsed.data.metadata !== undefined
|
||||
? (parsed.data.metadata as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
},
|
||||
select: { id: true, createdAt: true, category: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("feedback.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: feedback.id,
|
||||
category: feedback.category,
|
||||
hasScreenshot: Boolean(parsed.data.screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "feedback.submit" },
|
||||
meta: {
|
||||
feedback_id: feedback.id,
|
||||
category: feedback.category,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(feedback, 201);
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
50,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 25)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return apiSuccess({ items, meta: { total, limit, offset } });
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe("GET /api/gamification/achievements — hidden achievement redaction",
|
||||
for (const entry of hiddenLocked) {
|
||||
// Trigger semantics scrubbed
|
||||
expect(entry.metric ?? "").not.toMatch(
|
||||
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip|bugReport)Count/,
|
||||
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip)Count/,
|
||||
);
|
||||
// i18n keys scrubbed
|
||||
expect(entry.titleKey ?? "").not.toContain("hiddenNightOwl");
|
||||
|
||||
@@ -57,12 +57,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// Destroy all sessions first
|
||||
await destroyAllSessions(userId);
|
||||
|
||||
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: Feedback and AuditLog rows have
|
||||
// `onDelete: SetNull` in the schema, which keeps PII (free-text symptom
|
||||
// descriptions, IP addresses, login city geo) attached to the record after
|
||||
// the user is deleted. We explicitly purge them inside the same logical
|
||||
// operation so account deletion is genuinely complete erasure.
|
||||
await prisma.feedback.deleteMany({ where: { userId } });
|
||||
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have
|
||||
// `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login
|
||||
// city geo) attached to the record after the user is deleted. We explicitly
|
||||
// purge them inside the same logical operation so account deletion is
|
||||
// genuinely complete erasure.
|
||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||
|
||||
// Delete user — all other related data is removed via onDelete: Cascade
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loader2, CheckCircle2, Bug, GitPullRequest, Info } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
interface BugReportStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "BUG", labelKey: "bugreport.categoryBug" },
|
||||
{ value: "FEATURE_REQUEST", labelKey: "bugreport.categoryFeature" },
|
||||
{ value: "QUESTION", labelKey: "bugreport.categoryQuestion" },
|
||||
{ value: "OTHER", labelKey: "bugreport.categoryOther" },
|
||||
] as const;
|
||||
|
||||
type CategoryValue = (typeof CATEGORIES)[number]["value"];
|
||||
|
||||
export default function BugReportPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const [category, setCategory] = useState<CategoryValue>("BUG");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// Status is now informational only — submission always works.
|
||||
const { data: status } = useQuery({
|
||||
queryKey: queryKeys.bugreportStatus(),
|
||||
queryFn: async () => {
|
||||
return apiGet<BugReportStatus>("/api/bugreport/status");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setResult(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await apiPost("/api/feedback", {
|
||||
category,
|
||||
subject: subject || description.slice(0, 60),
|
||||
description,
|
||||
metadata: {
|
||||
locale,
|
||||
userAgent:
|
||||
typeof navigator !== "undefined" ? navigator.userAgent : null,
|
||||
url: typeof window !== "undefined" ? window.location.pathname : null,
|
||||
},
|
||||
});
|
||||
setResult({ type: "success", message: t("bugreport.success") });
|
||||
setSubject("");
|
||||
setDescription("");
|
||||
} catch (err) {
|
||||
setResult({
|
||||
type: "error",
|
||||
message:
|
||||
err instanceof ApiError
|
||||
? err.message || t("bugreport.errorCreating")
|
||||
: t("common.networkError"),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("bugreport.loginRequired")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && status.enabled === false) {
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("bugreport.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card border-border border-l-warning flex gap-2 rounded-lg border-l-4 p-3 text-sm">
|
||||
<Info className="text-warning mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-foreground font-medium">
|
||||
{t("bugreport.disabledTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("bugreport.disabledBody")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("bugreport.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status?.configured && (
|
||||
<div className="bg-card border-border border-l-info flex gap-2 rounded-lg border-l-4 p-3 text-sm">
|
||||
<GitPullRequest className="text-info mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("bugreport.githubEscalationNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!status?.configured && (
|
||||
<div className="bg-card border-border border-l-dose-accent flex gap-2 rounded-lg border-l-4 p-3 text-sm">
|
||||
<Info className="text-dose-accent mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("bugreport.internalOnlyNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card border-border w-full rounded-xl border p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-[200px_1fr]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">{t("bugreport.category")}</Label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(v) => setCategory(v as CategoryValue)}
|
||||
>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
{t(c.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{t("bugreport.bugTitle")}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t("bugreport.bugTitlePlaceholder")}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bug-desc">{t("bugreport.description")}</Label>
|
||||
<Textarea
|
||||
id="bug-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
result.type === "success"
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{result.type === "success" && (
|
||||
<CheckCircle2 className="mr-2 inline h-4 w-4" />
|
||||
)}
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
aria-busy={loading || undefined}
|
||||
>
|
||||
{/*
|
||||
v1.4.33 IW9 — swap the icon on loading (Loader2 takes
|
||||
the same 16x16 slot as the Bug icon) so the button
|
||||
width is stable across the disabled flip. The previous
|
||||
`{loading && <Loader2/>}<Bug/>` rendered both icons
|
||||
during the in-flight request and grew the button by
|
||||
~24 px — the CLS regression flagged in the v1.4.33
|
||||
Lighthouse punch list (0.186 on
|
||||
`div.space-y-2 > Button > Loader-Circle`).
|
||||
*/}
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Bug className="h-4 w-4" />
|
||||
)}
|
||||
{t("bugreport.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,7 +133,7 @@ export default function NotificationsPage() {
|
||||
{t("notifications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("bugreport.loginRequired")}
|
||||
{t("notifications.loginRequired")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1581,7 +1581,7 @@ export default function PrivacyPage() {
|
||||
<span className="text-foreground font-medium">
|
||||
Nutzer-Inhalte
|
||||
</span>{" "}
|
||||
(Notizen zu Messungen, Stimmungsnotizen, Bug-Reports).
|
||||
(Notizen zu Messungen, Stimmungsnotizen).
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">Kennungen</span>{" "}
|
||||
|
||||
@@ -6,7 +6,7 @@ React components. The UI primitives are shadcn/ui (new-york style, zinc base) ov
|
||||
|
||||
- **`ui/`** — shadcn/ui primitives (button, dialog, sheet, table, …). Generated by the shadcn CLI; keep edits minimal and on-style.
|
||||
- **`layout/`** — the app shell: sidebar, topbar, bottom nav.
|
||||
- **`providers.tsx`, `app-settings-provider.tsx`, `version-poller.tsx`** — top-level context providers and the self-healing stale-shell poller.
|
||||
- **`providers.tsx`, `version-poller.tsx`** — top-level context providers and the self-healing stale-shell poller.
|
||||
- **`i18n/`** — translation context + locale switcher.
|
||||
|
||||
## Feature groups
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// `<ErrorDetails>` is a client component used by every Next.js
|
||||
// `error.tsx` boundary. It surfaces a "Report bug" link whose
|
||||
// visibility must mirror the admin's `bugReportEnabled` toggle —
|
||||
// otherwise the user lands on a `/bugreport` page that immediately
|
||||
// renders a "Bug reports are disabled" notice.
|
||||
|
||||
const mockSettingsRef = { value: { bugReportEnabled: true } };
|
||||
vi.mock("@/components/app-settings-provider", () => ({
|
||||
useAppSettings: () => mockSettingsRef.value,
|
||||
}));
|
||||
// `<ErrorDetails>` is a client component used by every Next.js `error.tsx`
|
||||
// boundary. It surfaces the error message plus a copy-to-clipboard diagnostic
|
||||
// payload — and, since v1.23, no longer links out to a bug-report surface.
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ErrorDetails } from "../error-details";
|
||||
|
||||
function render({
|
||||
bugReportEnabled = true,
|
||||
}: { bugReportEnabled?: boolean } = {}) {
|
||||
mockSettingsRef.value = { bugReportEnabled };
|
||||
function render() {
|
||||
const error = Object.assign(new Error("test failure"), {
|
||||
digest: "abc123",
|
||||
});
|
||||
@@ -29,14 +19,14 @@ function render({
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ErrorDetails> bug-report toggle", () => {
|
||||
it("shows the Report bug button when the admin flag is enabled", () => {
|
||||
const html = render({ bugReportEnabled: true });
|
||||
expect(html).toContain('href="/bugreport"');
|
||||
describe("<ErrorDetails>", () => {
|
||||
it("renders the error message", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("test failure");
|
||||
});
|
||||
|
||||
it("hides the Report bug button when the admin flag is disabled", () => {
|
||||
const html = render({ bugReportEnabled: false });
|
||||
it("does not link out to a bug-report surface", () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain('href="/bugreport"');
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,6 @@ import { AiQualitySection } from "../ai-quality-section";
|
||||
import { ApiTokenOverviewSection } from "../api-token-overview-section";
|
||||
import { BackupsSection } from "../backups-section";
|
||||
import { DangerZoneSection } from "../danger-zone-section";
|
||||
import { FeedbackInboxSection } from "../feedback-inbox-section";
|
||||
import { GeneralSettingsSection } from "../general-settings-section";
|
||||
import { IntegrationsGroupSection } from "../integrations-group-section";
|
||||
import { LoginOverviewSection } from "../login-overview-section";
|
||||
@@ -89,22 +88,13 @@ describe("admin sections — SSR smoke", () => {
|
||||
expect(html).toContain("Telegram");
|
||||
});
|
||||
|
||||
it("<IntegrationsGroupSection> renders all four sub-sections", () => {
|
||||
it("<IntegrationsGroupSection> renders all sub-sections", () => {
|
||||
const html = render(<IntegrationsGroupSection />);
|
||||
expect(html).toContain("Umami");
|
||||
expect(html).toContain("GlitchTip");
|
||||
expect(html).toContain("Web Push");
|
||||
});
|
||||
|
||||
it("<FeedbackInboxSection> renders", () => {
|
||||
const html = render(<FeedbackInboxSection />);
|
||||
// F-08 dedupe: card no longer carries a duplicate "Feedback"
|
||||
// header. Anchor on the empty-state copy + status tabs instead.
|
||||
expect(html).toContain("All caught up");
|
||||
expect(html).toContain("Open");
|
||||
expect(html).toContain("Resolved");
|
||||
});
|
||||
|
||||
it("<RemindersSection> renders", () => {
|
||||
const html = render(<RemindersSection />);
|
||||
expect(html).toContain("Medication Reminders");
|
||||
|
||||
@@ -60,7 +60,6 @@ export interface SystemStatus {
|
||||
umami: { configured: boolean; enabled: boolean } | null;
|
||||
glitchtip: { configured: boolean; enabled: boolean } | null;
|
||||
webPush: { configured: boolean } | null;
|
||||
bugReport: { configured: boolean } | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,9 +79,6 @@ export interface AdminSettings {
|
||||
glitchtipEnabled: boolean;
|
||||
glitchtipDsn: string | null;
|
||||
glitchtipEnvironment: string | null;
|
||||
bugReportRepo: string | null;
|
||||
bugReportConfigured: boolean;
|
||||
bugReportEnabled: boolean;
|
||||
reminderLateMinutes: number;
|
||||
reminderMissedMinutes: number;
|
||||
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
|
||||
@@ -116,54 +112,9 @@ export interface ApiTokenInfo {
|
||||
user: { id: string; username: string };
|
||||
}
|
||||
|
||||
export type FeedbackStatusType =
|
||||
| "OPEN"
|
||||
| "ACKNOWLEDGED"
|
||||
| "RESOLVED"
|
||||
| "ARCHIVED";
|
||||
export type FeedbackCategoryType =
|
||||
| "BUG"
|
||||
| "FEATURE_REQUEST"
|
||||
| "QUESTION"
|
||||
| "OTHER";
|
||||
|
||||
export interface FeedbackItem {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
category: FeedbackCategoryType;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: FeedbackStatusType;
|
||||
adminNote: string | null;
|
||||
gitHubIssueUrl: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
screenshotBase64: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: { username: string } | null;
|
||||
}
|
||||
|
||||
export interface FeedbackListResponse {
|
||||
items: FeedbackItem[];
|
||||
meta: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
countsByStatus: Partial<Record<FeedbackStatusType, number>>;
|
||||
};
|
||||
}
|
||||
|
||||
export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [
|
||||
"OPEN",
|
||||
"ACKNOWLEDGED",
|
||||
"RESOLVED",
|
||||
"ARCHIVED",
|
||||
];
|
||||
|
||||
/** Shared green "configured" pill — the GlitchTip, Umami, bug-report,
|
||||
* and Web-Push VAPID cards all surface the same state; one component
|
||||
* keeps the tint and copy in lockstep. */
|
||||
/** Shared green "configured" pill — the GlitchTip, Umami, and Web-Push
|
||||
* VAPID cards all surface the same state; one component keeps the tint
|
||||
* and copy in lockstep. */
|
||||
export function ConfiguredBadge() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
FileText,
|
||||
Info,
|
||||
KeyRound,
|
||||
Inbox,
|
||||
Plug,
|
||||
Radio,
|
||||
ScrollText,
|
||||
@@ -89,11 +88,6 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
titleKey: "admin.section.module-availability.title",
|
||||
icon: Blocks,
|
||||
},
|
||||
{
|
||||
slug: "feedback",
|
||||
titleKey: "admin.section.feedback.title",
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
slug: "reminders",
|
||||
titleKey: "admin.section.reminders.title",
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bug, Loader2 } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
ConfiguredBadge,
|
||||
PasswordInput,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function BugReportSection() {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [bugReportRepoDraft, setBugReportRepoDraft] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [bugReportTokenDraft, setBugReportTokenDraft] = useState("");
|
||||
|
||||
const bugReportRepoValue =
|
||||
bugReportRepoDraft ?? settings?.bugReportRepo ?? "";
|
||||
const configured = settings?.bugReportConfigured ?? false;
|
||||
const enabled = settings?.bugReportEnabled ?? true;
|
||||
|
||||
function saveBugReportSettings() {
|
||||
const payload: Record<string, unknown> = {
|
||||
bugReportRepo: bugReportRepoValue,
|
||||
};
|
||||
if (bugReportTokenDraft.trim().length > 0) {
|
||||
payload.bugReportToken = bugReportTokenDraft.trim();
|
||||
}
|
||||
|
||||
updateSettings.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
setBugReportRepoDraft(null);
|
||||
setBugReportTokenDraft("");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEnabled(next: boolean) {
|
||||
updateSettings.mutate({ bugReportEnabled: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Bug}
|
||||
title={t("admin.bugReportGithub")}
|
||||
titleAccessory={
|
||||
// v1.18.10 (W7) — the feedback / bug-report feature is deprecated.
|
||||
// The badge + note signal the upcoming removal without pulling the
|
||||
// functionality yet, so existing operators keep the surface working.
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="bug-report-deprecated-badge"
|
||||
className="border-amber-500/50 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{t("admin.bugReportDeprecatedBadge")}
|
||||
</Badge>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<p>{t("admin.bugReportGithubDescription")}</p>
|
||||
<p className="text-amber-600 dark:text-amber-400">
|
||||
{t("admin.bugReportDeprecatedNote")}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="bg-muted/40 mt-4 ml-7 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.bugReportEnabledLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.bugReportEnabledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={toggleEnabled}
|
||||
disabled={updateSettings.isPending}
|
||||
aria-label={t("admin.bugReportEnabledLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-bugreport-repo" className="text-xs">
|
||||
{t("admin.bugReportRepo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-bugreport-repo"
|
||||
value={bugReportRepoValue}
|
||||
onChange={(event) => setBugReportRepoDraft(event.target.value)}
|
||||
placeholder={t("admin.bugReportRepoPlaceholder")}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-bugreport-token" className="text-xs">
|
||||
{t("admin.bugReportToken")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="admin-bugreport-token"
|
||||
value={bugReportTokenDraft}
|
||||
onChange={(event) => setBugReportTokenDraft(event.target.value)}
|
||||
placeholder={t("admin.bugReportTokenPlaceholder")}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveBugReportSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Inbox,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
FEEDBACK_STATUS_TABS,
|
||||
type FeedbackCategoryType,
|
||||
type FeedbackItem,
|
||||
type FeedbackListResponse,
|
||||
type FeedbackStatusType,
|
||||
useSystemStatus,
|
||||
} from "./_shared";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
export function FeedbackInboxSection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: status } = useSystemStatus();
|
||||
const githubConfigured = Boolean(status?.integrations?.bugReport?.configured);
|
||||
|
||||
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
|
||||
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.adminFeedback(activeStatus),
|
||||
queryFn: async () => {
|
||||
return apiGet<FeedbackListResponse>(
|
||||
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const counts = data?.meta?.countsByStatus ?? {};
|
||||
|
||||
function refresh() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminFeedbackRoot() });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* v1.4.19 A8 / F-08 + F-20: this is the only card on
|
||||
`/admin/feedback`, so the page header already provides the
|
||||
title + description — the card-level repetition was just
|
||||
visual noise. The icon stays as a low-key card affordance. */}
|
||||
<Inbox className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
|
||||
<Tabs
|
||||
value={activeStatus}
|
||||
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
|
||||
className="mt-4"
|
||||
>
|
||||
{/* v1.4.27 MB7 / CF-55 — wrap the TabsList in an
|
||||
overflow-x-auto strip so the 4 status pills + their count
|
||||
badges scroll horizontally on Pixel 5 / Galaxy Fold
|
||||
instead of squashing the badge text below the legibility
|
||||
floor. Hide the scrollbar visually but keep the gesture
|
||||
working (matches the Insights tab-strip pattern). */}
|
||||
<div className="-mx-1 [scrollbar-width:none] overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden">
|
||||
<TabsList>
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
<span>
|
||||
{t(
|
||||
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{counts[s] ?? 0}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsContent key={s} value={s} className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : !data?.items?.length ? (
|
||||
// v1.4.15 phase-C5: per-tab empty state. Compact size so
|
||||
// it sits naturally under the tab strip without dwarfing
|
||||
// a typical 1-2-row inbox once entries arrive.
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={<Inbox className="size-5" />}
|
||||
title={t("admin.feedback.emptyTitle")}
|
||||
description={t("admin.feedback.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.createdAt")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.category")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.subject")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.user")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.feedback.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{data.items.map((item, i) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
</td>
|
||||
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
|
||||
{item.subject}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelected(item);
|
||||
}}
|
||||
>
|
||||
{t("admin.feedback.details")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{selected && (
|
||||
<FeedbackDetailDialog
|
||||
item={selected}
|
||||
open={!!selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
githubConfigured={githubConfigured}
|
||||
onMutated={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCategoryBadge({
|
||||
category,
|
||||
}: {
|
||||
category: FeedbackCategoryType;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const map: Record<
|
||||
FeedbackCategoryType,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
BUG: {
|
||||
label: t("admin.feedback.categoryBug"),
|
||||
// Bug = something that's broken → semantic `destructive` token instead
|
||||
// of raw `dracula-red`. Visually identical in dark mode (both resolve
|
||||
// to #ff5555); light mode now picks up the AA-contrast `#cb3a2a`.
|
||||
className: "bg-destructive/15 text-destructive border-destructive/30",
|
||||
},
|
||||
FEATURE_REQUEST: {
|
||||
label: t("admin.feedback.categoryFeature"),
|
||||
className:
|
||||
"bg-dracula-purple/15 text-dracula-purple border-dracula-purple/30",
|
||||
},
|
||||
QUESTION: {
|
||||
label: t("admin.feedback.categoryQuestion"),
|
||||
className: "bg-dracula-cyan/15 text-dracula-cyan border-dracula-cyan/30",
|
||||
},
|
||||
OTHER: {
|
||||
label: t("admin.feedback.categoryOther"),
|
||||
className: "bg-muted text-muted-foreground border-border",
|
||||
},
|
||||
};
|
||||
const cfg = map[category];
|
||||
return (
|
||||
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackDetailDialog({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
githubConfigured,
|
||||
onMutated,
|
||||
}: {
|
||||
item: FeedbackItem;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
githubConfigured: boolean;
|
||||
onMutated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [note, setNote] = useState(item.adminNote ?? "");
|
||||
const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl);
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
status?: FeedbackStatusType;
|
||||
adminNote?: string | null;
|
||||
}) => {
|
||||
await apiPatch(`/api/admin/feedback/${item.id}`, payload);
|
||||
},
|
||||
onSuccess: (_, vars) => {
|
||||
onMutated();
|
||||
if (vars.adminNote !== undefined) {
|
||||
toast.success(t("admin.feedback.noteSaved"));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiDelete(`/api/admin/feedback/${item.id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
onMutated();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<{ issueUrl: string }>(
|
||||
`/api/admin/feedback/${item.id}/github`,
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setIssueUrl(data.issueUrl);
|
||||
toast.success(t("admin.feedback.publishSuccess"));
|
||||
onMutated();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.publishFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const meta = item.metadata ?? {};
|
||||
const url = typeof meta.url === "string" ? meta.url : null;
|
||||
const locale = typeof meta.locale === "string" ? meta.locale : null;
|
||||
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
|
||||
const appVersion =
|
||||
typeof meta.appVersion === "string" ? meta.appVersion : null;
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
{item.subject}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
<span>
|
||||
{t("admin.feedback.submittedBy")}:{" "}
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
{issueUrl && (
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1"
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{t("admin.feedback.viewIssue")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
className="sm:max-w-2xl"
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
|
||||
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionAcknowledge")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending || item.status === "RESOLVED"}
|
||||
onClick={() => update.mutate({ status: "RESOLVED" })}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionResolve")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archive.isPending || item.status === "ARCHIVED"}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.feedback.archiveConfirm")}
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => archive.mutate()}
|
||||
variant="destructive"
|
||||
disabled={archive.isPending}
|
||||
aria-busy={archive.isPending || undefined}
|
||||
>
|
||||
{archive.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{githubConfigured && !issueUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={publish.isPending}
|
||||
onClick={() => publish.mutate()}
|
||||
className="ml-auto"
|
||||
>
|
||||
{publish.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<GitPullRequest className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.feedback.actionPublishGithub")}
|
||||
</Button>
|
||||
)}
|
||||
{issueUrl && (
|
||||
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
|
||||
{t("admin.feedback.publishedToGithub")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
|
||||
sequential beneath the ResponsiveSheet's h2 title. */}
|
||||
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h3>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{url && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUrl")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{url}</dd>
|
||||
</>
|
||||
)}
|
||||
{locale && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaLocale")}
|
||||
</dt>
|
||||
<dd className="font-mono">{locale}</dd>
|
||||
</>
|
||||
)}
|
||||
{appVersion && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaAppVersion")}
|
||||
</dt>
|
||||
<dd className="font-mono">{appVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{userAgent && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUserAgent")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{userAgent}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h3>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-feedback-note" className="text-xs">
|
||||
{t("admin.feedback.adminNote")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="admin-feedback-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || note === (item.adminNote ?? "")}
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,11 @@
|
||||
/**
|
||||
* `<IntegrationsGroupSection>` — composite admin route at `/admin/integrations`.
|
||||
*
|
||||
* Wraps the four standalone integration sub-sections (Umami, GlitchTip, Web
|
||||
* Push, Bug Report) under one route so each existing component stays the
|
||||
* source of truth for its own settings while admins still get a single
|
||||
* landing page mirroring the v1.4 layout.
|
||||
* Wraps the standalone integration sub-sections (Umami, GlitchTip, Web Push)
|
||||
* under one route so each existing component stays the source of truth for its
|
||||
* own settings while admins still get a single landing page.
|
||||
*/
|
||||
|
||||
import { BugReportSection } from "./bug-report-section";
|
||||
import { GlitchtipSection } from "./glitchtip-section";
|
||||
import { UmamiSection } from "./umami-section";
|
||||
import { WebPushVapidSection } from "./web-push-vapid-section";
|
||||
@@ -20,7 +18,6 @@ export function IntegrationsGroupSection() {
|
||||
<UmamiSection />
|
||||
<GlitchtipSection />
|
||||
<WebPushVapidSection />
|
||||
<BugReportSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export const ADMIN_SECTION_SLUGS = [
|
||||
// (was stacked under Coach). It gates every module, not just the coach,
|
||||
// so it reads better next to Coach than buried inside it.
|
||||
"module-availability",
|
||||
"feedback",
|
||||
"reminders",
|
||||
"users",
|
||||
// v1.16.0 — registration invites get their own section. They started
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
BellRing,
|
||||
Bug,
|
||||
Clock,
|
||||
Cog,
|
||||
Database,
|
||||
@@ -160,14 +159,6 @@ export function SystemStatusSection() {
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
{status.integrations.bugReport && (
|
||||
<StatusItem
|
||||
icon={Bug}
|
||||
label={t("admin.integrationBugReport")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.27 R5 — offline GeoLite2 availability. Renders only
|
||||
when /api/version returns the new field so legacy
|
||||
responses do not produce a placeholder row. */}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Client-side feature-flag context — exposes admin-managed app settings to
|
||||
* layout components so nav surfaces can hide entries when the admin flips a
|
||||
* toggle.
|
||||
*
|
||||
* Today this only carries the `bugReportEnabled` flag (the "Bug Report"
|
||||
* sidebar entry, the bottom-nav, the topbar dropdown, and the user-facing
|
||||
* "Report bug" buttons inside `<ErrorDetails>` all gate on it). Add new
|
||||
* flags here when more admin-managed feature toggles need to reach the
|
||||
* client.
|
||||
*
|
||||
* Source: `GET /api/bugreport/status` already returns `{ enabled }` for any
|
||||
* authenticated user. We piggy-back on that endpoint instead of standing up
|
||||
* a parallel `/api/app-settings` route — the data is identical and the
|
||||
* caching / rate-limit story is already solved upstream.
|
||||
*
|
||||
* Defaults to "all flags ON" so unauthenticated layouts (login, register)
|
||||
* and the brief window before the query resolves don't flicker the entry
|
||||
* out from underneath the user. The admin's "off" state only takes effect
|
||||
* once the data has actually arrived.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface AppSettings {
|
||||
/** Whether the admin has enabled in-app bug-report / feedback submission. */
|
||||
bugReportEnabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
bugReportEnabled: true,
|
||||
};
|
||||
|
||||
const AppSettingsContext = createContext<AppSettings>(DEFAULT_SETTINGS);
|
||||
|
||||
export function useAppSettings(): AppSettings {
|
||||
return useContext(AppSettingsContext);
|
||||
}
|
||||
|
||||
interface BugReportStatusPayload {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches `bugReportEnabled` once per session and exposes it via context. The
|
||||
* provider lives inside `Providers` so anything beneath the React tree —
|
||||
* including the global nav surfaces — can observe the flag without
|
||||
* each component re-fetching the same status payload.
|
||||
*/
|
||||
export function AppSettingsProvider({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.bugreportStatus(),
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
return apiGet<BugReportStatusPayload>("/api/bugreport/status");
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Default to ON when the query is still loading, has errored, or the user
|
||||
// is unauthenticated — the nav must not flicker the entry out under the
|
||||
// user's cursor on a slow network.
|
||||
const settings: AppSettings = {
|
||||
bugReportEnabled: data?.enabled ?? true,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppSettingsContext.Provider value={settings}>
|
||||
{children}
|
||||
</AppSettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, CheckCircle2, RotateCcw, Bug } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Copy, CheckCircle2, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAppSettings } from "@/components/app-settings-provider";
|
||||
|
||||
export interface ErrorDetailsProps {
|
||||
/** The error object, whether it's a runtime Error or a typed API error. */
|
||||
@@ -16,8 +14,6 @@ export interface ErrorDetailsProps {
|
||||
requestId?: string | null;
|
||||
/** Extra fields to include in the diagnostic payload. */
|
||||
context?: Record<string, unknown>;
|
||||
/** Override the "Report bug" link target. */
|
||||
reportHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,10 +26,8 @@ export function ErrorDetails({
|
||||
reset,
|
||||
requestId,
|
||||
context,
|
||||
reportHref = "/bugreport",
|
||||
}: ErrorDetailsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { bugReportEnabled } = useAppSettings();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
@@ -102,14 +96,6 @@ export function ErrorDetails({
|
||||
)}
|
||||
{copied ? t("common.copied") : t("common.copyDetails")}
|
||||
</Button>
|
||||
{bugReportEnabled && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={reportHref}>
|
||||
<Bug className="h-4 w-4" />
|
||||
{t("common.reportIssue")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -74,28 +74,14 @@ describe("nav-model utility tail (N-1 — one shared list)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lists the shared utility surfaces (Bug Report, Settings, Notifications)", () => {
|
||||
it("lists the shared utility surfaces (Settings, Notifications)", () => {
|
||||
const hrefs = NAV_UTILITY_DESTINATIONS.map((d) => d.href);
|
||||
expect(hrefs).toContain("/bugreport");
|
||||
expect(hrefs).toContain("/settings/account");
|
||||
expect(hrefs).toContain("/notifications");
|
||||
});
|
||||
|
||||
it("drops the bug-report entry unless the operator flag is on", () => {
|
||||
const off = visibleUtilityDestinations(false).map((d) => d.href);
|
||||
const on = visibleUtilityDestinations(true).map((d) => d.href);
|
||||
expect(off).not.toContain("/bugreport");
|
||||
expect(on).toContain("/bugreport");
|
||||
// Non-gated entries are present either way.
|
||||
expect(off).toContain("/settings/account");
|
||||
expect(off).toContain("/notifications");
|
||||
});
|
||||
|
||||
it("keeps a stable order (Bug Report → Settings → Notifications)", () => {
|
||||
const hrefs = visibleUtilityDestinations(true).map((d) => d.href);
|
||||
expect(hrefs.indexOf("/bugreport")).toBeLessThan(
|
||||
hrefs.indexOf("/settings/account"),
|
||||
);
|
||||
it("keeps a stable order (Settings → Notifications)", () => {
|
||||
const hrefs = visibleUtilityDestinations().map((d) => d.href);
|
||||
expect(hrefs.indexOf("/settings/account")).toBeLessThan(
|
||||
hrefs.indexOf("/notifications"),
|
||||
);
|
||||
@@ -178,16 +164,13 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
it("is exactly the visible feature destinations minus the primary slots, then the utility tail", () => {
|
||||
const opts = {
|
||||
modules: { cycle: false } as const,
|
||||
bugReportEnabled: false,
|
||||
};
|
||||
const hub = mobileMoreHubDestinations(opts).map((d) => d.href);
|
||||
|
||||
const expectedFeatures = visibleNavDestinations(opts.modules)
|
||||
.map((d) => d.href)
|
||||
.filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href));
|
||||
const expectedTail = visibleUtilityDestinations(opts.bugReportEnabled).map(
|
||||
(d) => d.href,
|
||||
);
|
||||
const expectedTail = visibleUtilityDestinations().map((d) => d.href);
|
||||
|
||||
expect(hub).toEqual([...expectedFeatures, ...expectedTail]);
|
||||
});
|
||||
@@ -195,7 +178,6 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
it("never contains a primary slot (Home / Meds / Insights)", () => {
|
||||
const hub = mobileMoreHubDestinations({
|
||||
modules: { cycle: true },
|
||||
bugReportEnabled: true,
|
||||
}).map((d) => d.href);
|
||||
for (const slot of BOTTOM_NAV_PRIMARY_SLOT_HREFS) {
|
||||
expect(hub).not.toContain(slot);
|
||||
@@ -205,33 +187,27 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
it("keeps the feature destinations and the utility tail reachable in the hub", () => {
|
||||
const hub = mobileMoreHubDestinations({
|
||||
modules: { cycle: false },
|
||||
bugReportEnabled: true,
|
||||
}).map((d) => d.href);
|
||||
// Feature surfaces that left the always-visible strip stay reachable.
|
||||
expect(hub).toContain("/measurements");
|
||||
expect(hub).toContain("/mood");
|
||||
expect(hub).toContain("/checkups");
|
||||
// The shared utility tail rides at the end.
|
||||
expect(hub).toContain("/bugreport");
|
||||
expect(hub).toContain("/settings/account");
|
||||
expect(hub).toContain("/notifications");
|
||||
});
|
||||
|
||||
it("gates Cycle by the module map and Bug Report by the operator flag, same as the sidebar", () => {
|
||||
it("gates Cycle by the module map, same as the sidebar", () => {
|
||||
const off = mobileMoreHubDestinations({
|
||||
modules: { cycle: false, mood: false },
|
||||
bugReportEnabled: false,
|
||||
}).map((d) => d.href);
|
||||
const on = mobileMoreHubDestinations({
|
||||
modules: { cycle: true, mood: true },
|
||||
bugReportEnabled: true,
|
||||
}).map((d) => d.href);
|
||||
expect(off).not.toContain("/cycle");
|
||||
expect(off).not.toContain("/mood");
|
||||
expect(off).not.toContain("/bugreport");
|
||||
expect(on).toContain("/cycle");
|
||||
expect(on).toContain("/mood");
|
||||
expect(on).toContain("/bugreport");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,13 +43,6 @@ vi.mock("@/components/providers", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The bug-report flag — each test mutates `mockSettingsRef.value` before
|
||||
// rendering so the sidebar reads the desired state.
|
||||
const mockSettingsRef = { value: { bugReportEnabled: true } };
|
||||
vi.mock("@/components/app-settings-provider", () => ({
|
||||
useAppSettings: () => mockSettingsRef.value,
|
||||
}));
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SidebarNav } from "../sidebar-nav";
|
||||
@@ -58,17 +51,14 @@ import { visibleUtilityDestinations } from "../nav-model";
|
||||
|
||||
function render({
|
||||
pathname = "/",
|
||||
bugReportEnabled = true,
|
||||
role = "USER" as "USER" | "ADMIN",
|
||||
modules = {} as Record<string, boolean>,
|
||||
}: {
|
||||
pathname?: string;
|
||||
bugReportEnabled?: boolean;
|
||||
role?: "USER" | "ADMIN";
|
||||
modules?: Record<string, boolean>;
|
||||
} = {}) {
|
||||
mockPathnameRef.value = pathname;
|
||||
mockSettingsRef.value = { bugReportEnabled };
|
||||
mockUserRef.value = { ...mockUserRef.value, role, modules };
|
||||
return renderToStaticMarkup(
|
||||
// The nav reads `useQueryClient()` for the medications intent
|
||||
@@ -82,21 +72,6 @@ function render({
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SidebarNav> bug-report toggle", () => {
|
||||
it("renders the Bug Report entry when the admin flag is enabled", () => {
|
||||
const html = render({ bugReportEnabled: true });
|
||||
expect(html).toContain('href="/bugreport"');
|
||||
// i18n string for the entry — sanity check that the link is the actual
|
||||
// bug-report nav entry, not e.g. an error-page report-bug button.
|
||||
expect(html).toContain("Bug Report");
|
||||
});
|
||||
|
||||
it("hides the Bug Report entry when the admin flag is disabled", () => {
|
||||
const html = render({ bugReportEnabled: false });
|
||||
expect(html).not.toContain('href="/bugreport"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> cycle entry gate (v1.15.0 / v1.18.0 module map)", () => {
|
||||
it("renders the Cycle entry when the cycle module is enabled", () => {
|
||||
const html = render({ modules: { cycle: true } });
|
||||
@@ -212,28 +187,17 @@ describe("<SidebarNav> shared utility tail (v1.17.1 N-1 parity guard)", () => {
|
||||
// therefore the shared list minus `/notifications`. Asserting they all
|
||||
// render straight from the shared list is the parity net that keeps the
|
||||
// sidebar footer from drifting away from the mobile More-hub tail.
|
||||
const footerUtilities = (bugReportEnabled: boolean) =>
|
||||
visibleUtilityDestinations(bugReportEnabled).filter(
|
||||
(d) => d.href !== "/notifications",
|
||||
);
|
||||
const footerUtilities = () =>
|
||||
visibleUtilityDestinations().filter((d) => d.href !== "/notifications");
|
||||
|
||||
it("renders every footer utility destination straight from the shared list", () => {
|
||||
const html = render({ bugReportEnabled: true });
|
||||
for (const dest of footerUtilities(true)) {
|
||||
const html = render();
|
||||
for (const dest of footerUtilities()) {
|
||||
expect(html).toContain(`href="${dest.href}"`);
|
||||
}
|
||||
// Bug Report and Settings are the footer utilities; both are present.
|
||||
expect(html).toContain('href="/bugreport"');
|
||||
// Settings is the footer utility entry; it is present.
|
||||
expect(html).toContain('href="/settings/account"');
|
||||
});
|
||||
|
||||
it("drops the bug-report utility entry when the operator flag is off", () => {
|
||||
const html = render({ bugReportEnabled: false });
|
||||
for (const dest of footerUtilities(false)) {
|
||||
expect(html).toContain(`href="${dest.href}"`);
|
||||
}
|
||||
expect(html).not.toContain('href="/bugreport"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", () => {
|
||||
|
||||
@@ -261,8 +261,8 @@ export function AuthShell({
|
||||
>
|
||||
{/*
|
||||
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
|
||||
(1280 px) so dashboard / settings / admin / bugreport all
|
||||
hit the same content frame. Pre-v1.4.33 the dashboard
|
||||
(1280 px) so dashboard / settings / admin all hit the
|
||||
same content frame. Pre-v1.4.33 the dashboard
|
||||
shell capped at `max-w-[76.8rem]` (1228 px) while the
|
||||
settings/admin shells used `max-w-screen-xl`, producing a
|
||||
52 px lateral wobble on every route switch. Same audit
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "@/components/layout/nav-model";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAppSettings } from "@/components/app-settings-provider";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -83,21 +82,19 @@ export function BottomNav() {
|
||||
// v1.16.7 — touch intent on the medications tab starts the list
|
||||
// request before the navigation commits (see sidebar-nav).
|
||||
const medsIntent = medicationsPrefetchIntentProps(queryClient);
|
||||
const { bugReportEnabled } = useAppSettings();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
|
||||
// feature destination that isn't a primary slot, plus the shared utility
|
||||
// tail. Cycle + Bug Report are gated by the same flags the sidebar uses,
|
||||
// so the two surfaces gate identically and cannot drift.
|
||||
// tail. Cycle is gated by the same flag the sidebar uses, so the two
|
||||
// surfaces gate identically and cannot drift.
|
||||
const moreHub = useMemo<ReadonlyArray<NavLink>>(
|
||||
() =>
|
||||
mobileMoreHubDestinations({
|
||||
modules: user?.modules,
|
||||
bugReportEnabled,
|
||||
}),
|
||||
[user?.modules, bugReportEnabled],
|
||||
[user?.modules],
|
||||
);
|
||||
|
||||
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Bug,
|
||||
Droplets,
|
||||
FlaskConical,
|
||||
Home,
|
||||
@@ -168,17 +167,15 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
/**
|
||||
* v1.17.1 (F-1 residue) — the shared UTILITY tail.
|
||||
*
|
||||
* Bug Report, Settings and Notifications are reachable on both bars but are
|
||||
* not feature destinations: on desktop they live in the sidebar footer +
|
||||
* avatar menu, on mobile at the tail of the "More" hub. They used to be a
|
||||
* second hand-curated list on each bar — the exact drift the one-model
|
||||
* contract above set out to kill, just pushed down a level. This list is
|
||||
* the single source both bars consume for the tail, so the two surfaces
|
||||
* can no longer disagree on which utility links exist or in which order.
|
||||
* Settings and Notifications are reachable on both bars but are not feature
|
||||
* destinations: on desktop they live in the sidebar footer + avatar menu, on
|
||||
* mobile at the tail of the "More" hub. They used to be a second hand-curated
|
||||
* list on each bar — the exact drift the one-model contract above set out to
|
||||
* kill, just pushed down a level. This list is the single source both bars
|
||||
* consume for the tail, so the two surfaces can no longer disagree on which
|
||||
* utility links exist or in which order.
|
||||
*
|
||||
* Order is the footer/hub order: Bug Report (gated) → Settings →
|
||||
* Notifications. `bugReportGated` entries drop when the operator flag is
|
||||
* off, exactly as the feature list drops cycle-gated entries.
|
||||
* Order is the footer/hub order: Settings → Notifications.
|
||||
*
|
||||
* Admin is intentionally NOT here: it is a role-gated, desktop-sidebar-only
|
||||
* surface (the mobile bar never exposes it), so it is not a shared tail
|
||||
@@ -189,33 +186,20 @@ export interface NavUtilityDestination {
|
||||
/** i18n key under the `nav.*` namespace. */
|
||||
tKey: string;
|
||||
icon: LucideIcon;
|
||||
/** Drop the entry unless the operator's bug-report flag is on. */
|
||||
bugReportGated?: boolean;
|
||||
}
|
||||
|
||||
export const NAV_UTILITY_DESTINATIONS: ReadonlyArray<NavUtilityDestination> = [
|
||||
{
|
||||
href: "/bugreport",
|
||||
tKey: "nav.bugreport",
|
||||
icon: Bug,
|
||||
bugReportGated: true,
|
||||
},
|
||||
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
|
||||
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
|
||||
];
|
||||
|
||||
/**
|
||||
* The utility tail visible to this account — drops the bug-report entry
|
||||
* when the operator flag is off. Both bars consume this for their tail
|
||||
* (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
|
||||
* The utility tail visible to this account. Both bars consume this for their
|
||||
* tail (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
|
||||
* surfaces share one definition of the utility links.
|
||||
*/
|
||||
export function visibleUtilityDestinations(
|
||||
bugReportEnabled: boolean | undefined,
|
||||
): NavUtilityDestination[] {
|
||||
return NAV_UTILITY_DESTINATIONS.filter(
|
||||
(d) => !d.bugReportGated || bugReportEnabled === true,
|
||||
);
|
||||
export function visibleUtilityDestinations(): NavUtilityDestination[] {
|
||||
return [...NAV_UTILITY_DESTINATIONS];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,19 +260,18 @@ export interface MobileMoreHubEntry {
|
||||
/**
|
||||
* The ordered "More" hub for the mobile bottom-nav: every visible feature
|
||||
* destination that isn't a primary slot, in model order, followed by the
|
||||
* shared utility tail (Bug Report behind its flag, Settings, Notifications).
|
||||
* The desktop sidebar renders the same feature list inline and consumes the
|
||||
* same utility tail in its footer + avatar menu, so the two bars cannot
|
||||
* drift into two hand-curated lists.
|
||||
* shared utility tail (Settings, Notifications). The desktop sidebar renders
|
||||
* the same feature list inline and consumes the same utility tail in its
|
||||
* footer + avatar menu, so the two bars cannot drift into two hand-curated
|
||||
* lists.
|
||||
*/
|
||||
export function mobileMoreHubDestinations(opts: {
|
||||
modules: ModuleVisibilityMap | undefined;
|
||||
bugReportEnabled: boolean | undefined;
|
||||
}): MobileMoreHubEntry[] {
|
||||
const features = visibleNavDestinations(opts.modules)
|
||||
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
||||
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
||||
const tail = visibleUtilityDestinations(opts.bugReportEnabled).map((d) => ({
|
||||
const tail = visibleUtilityDestinations().map((d) => ({
|
||||
href: d.href,
|
||||
tKey: d.tKey,
|
||||
icon: d.icon,
|
||||
|
||||
@@ -26,7 +26,6 @@ import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { useTheme } from "@/components/providers";
|
||||
import { useAppSettings } from "@/components/app-settings-provider";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
@@ -229,7 +228,6 @@ export function SidebarNav() {
|
||||
// list request before the navigation commits, so the due-time cells
|
||||
// hydrate from a warm cache instead of serialising behind the chunk.
|
||||
const medsIntent = medicationsPrefetchIntentProps(queryClient);
|
||||
const { bugReportEnabled } = useAppSettings();
|
||||
const isAdmin = user?.role === "ADMIN";
|
||||
// Match `/admin` exactly or any `/admin/...` sub-route for active-link
|
||||
// styling. Plain `startsWith("/admin")` would also flip for a
|
||||
@@ -276,10 +274,8 @@ export function SidebarNav() {
|
||||
// role-gated, sidebar-only surface and is inserted separately below.
|
||||
const footerUtilityItems = useMemo(
|
||||
() =>
|
||||
visibleUtilityDestinations(bugReportEnabled).filter(
|
||||
(d) => d.href !== "/notifications",
|
||||
),
|
||||
[bugReportEnabled],
|
||||
visibleUtilityDestinations().filter((d) => d.href !== "/notifications"),
|
||||
[],
|
||||
);
|
||||
|
||||
function isUtilityActive(href: string) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AppSettingsProvider } from "@/components/app-settings-provider";
|
||||
import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
@@ -211,14 +210,12 @@ export function Providers({
|
||||
initialLocale={initialLocale}
|
||||
initialMessages={initialMessages}
|
||||
>
|
||||
<AppSettingsProvider>
|
||||
<QueryPersistenceBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<VersionPoller />
|
||||
<ServiceWorkerRegistrar />
|
||||
</AppSettingsProvider>
|
||||
<QueryPersistenceBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<VersionPoller />
|
||||
<ServiceWorkerRegistrar />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
-13
@@ -8,7 +8,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { __resetAllCachesForTests, cached, caches } from "../server-cache";
|
||||
import {
|
||||
invalidateAppSettings,
|
||||
invalidateUserDashboardWidgets,
|
||||
invalidateUserDashboardSnapshot,
|
||||
invalidateUserInsights,
|
||||
@@ -95,8 +94,6 @@ async function primeAllCaches(): Promise<void> {
|
||||
dashboardSnapshotCacheKey(USER_B),
|
||||
async () => ({ snap: 2 }),
|
||||
);
|
||||
|
||||
await cached(caches.bugreportStatus, "singleton", async () => ({ s: 1 }));
|
||||
}
|
||||
|
||||
describe("invalidateUserMeasurements", () => {
|
||||
@@ -347,13 +344,3 @@ describe("invalidateUserProfile", () => {
|
||||
expect(caches.medications.get(USER_A)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateAppSettings", () => {
|
||||
it("evicts the entire bug-report status cache (singleton row)", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateAppSettings();
|
||||
expect(caches.bugreportStatus.get("singleton")).toBeNull();
|
||||
// Per-user caches untouched.
|
||||
expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -616,7 +616,6 @@ describe("global cache registry", () => {
|
||||
expect(caches.medications).toBeDefined();
|
||||
expect(caches.achievements).toBeDefined();
|
||||
expect(caches.dashboardWidgets).toBeDefined();
|
||||
expect(caches.bugreportStatus).toBeDefined();
|
||||
expect(caches.workouts).toBeDefined();
|
||||
expect(caches.medicationsIntake).toBeDefined();
|
||||
expect(caches.moodAnalytics).toBeDefined();
|
||||
|
||||
Vendored
-13
@@ -288,16 +288,3 @@ export function invalidateAllCaches(): void {
|
||||
cache.deleteByPrefix("");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global eviction of the bug-report-status cache.
|
||||
*
|
||||
* The cache is keyed on the singleton `"singleton"` slot because the
|
||||
* underlying data is a row in `AppSettings`. Any admin write to the
|
||||
* GitHub-token / bug-report-enabled fields invalidates the entire
|
||||
* cache; the per-user `isAdmin` flag is layered on at request time, so
|
||||
* the cache itself only stores role-agnostic shape.
|
||||
*/
|
||||
export function invalidateAppSettings(): void {
|
||||
caches.bugreportStatus.deleteByPrefix("");
|
||||
}
|
||||
Vendored
+1
-6
@@ -428,7 +428,7 @@ export class ServerCache<T> {
|
||||
*
|
||||
* The blueprint's full eight-cache roster is provisioned so future
|
||||
* rounds can wire the remaining routes (`/api/medications`,
|
||||
* `/api/dashboard/widgets`, `/api/bugreport/status`, `/api/workouts`,
|
||||
* `/api/dashboard/widgets`, `/api/workouts`,
|
||||
* `/api/mood/analytics`) without churning the helper module.
|
||||
*/
|
||||
export const caches = {
|
||||
@@ -511,11 +511,6 @@ export const caches = {
|
||||
maxEntries: 500,
|
||||
ttlMs: 300_000,
|
||||
}),
|
||||
bugreportStatus: new ServerCache<unknown>({
|
||||
name: "bugreportStatus",
|
||||
maxEntries: 10,
|
||||
ttlMs: 600_000,
|
||||
}),
|
||||
workouts: new ServerCache<unknown>({
|
||||
name: "workouts",
|
||||
maxEntries: 1000,
|
||||
|
||||
@@ -90,7 +90,6 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
field: "webPushVapidPrivateKeyEncrypted",
|
||||
kind: "string",
|
||||
},
|
||||
{ model: "AppSettings", field: "githubIssueTokenEncrypted", kind: "string" },
|
||||
|
||||
// ───── Custom labels (mood + cycle) ─────
|
||||
{ model: "MoodTag", field: "labelEncrypted", kind: "string" },
|
||||
|
||||
@@ -156,16 +156,6 @@ async function ensureAppSettingsSchema() {
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "glitchtip_environment" TEXT;
|
||||
`);
|
||||
|
||||
await prisma.$executeRawUnsafe(`
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "github_issue_token_encrypted" TEXT;
|
||||
`);
|
||||
|
||||
await prisma.$executeRawUnsafe(`
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "github_issue_repo" TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
async function ensureMedicationSchema() {
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* GitHub publisher for feedback items.
|
||||
*
|
||||
* Centralises the GitHub Issues API interaction so /api/bugreport (legacy
|
||||
* direct-submit) and /api/admin/feedback/[id]/github (admin-promotes-feedback)
|
||||
* share one implementation. Reads the configured token + repo from AppSettings
|
||||
* (encrypted) with env-var fallback.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
export interface FeedbackInput {
|
||||
category: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
username: string;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
issueNumber: number;
|
||||
issueUrl: string;
|
||||
}
|
||||
|
||||
export class GithubPublishError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GithubPublishError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the configured GitHub token + repo from AppSettings (or env).
|
||||
* Returns null if no configuration is present (caller should treat as 503).
|
||||
*/
|
||||
async function getGithubConfig(): Promise<{
|
||||
token: string;
|
||||
repo: string;
|
||||
} | null> {
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
},
|
||||
});
|
||||
|
||||
let configuredToken: string | null = null;
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
|
||||
} catch {
|
||||
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
|
||||
}
|
||||
}
|
||||
|
||||
const token = configuredToken || process.env.GITHUB_ISSUE_TOKEN || "";
|
||||
const repo =
|
||||
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO || "";
|
||||
|
||||
if (!token || !repo) return null;
|
||||
return { token, repo };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise a free-text field for GitHub markdown (strip control chars,
|
||||
* neutralise fenced code blocks, hard-cap length).
|
||||
*/
|
||||
function sanitize(text: string, max = 5000): string {
|
||||
return text.replace(/```/g, "\\`\\`\\`").slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe inclusion in a `RegExp(..)` constructor — the
|
||||
* standard "don't let an attacker turn input into regex metacharacters"
|
||||
* helper. Used to redact secrets from logged error bodies.
|
||||
*/
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a feedback item as a GitHub Issue. Returns issue number + URL.
|
||||
* Throws GithubPublishError on API failure.
|
||||
*/
|
||||
export async function publishFeedbackToGithub(
|
||||
input: FeedbackInput,
|
||||
): Promise<PublishResult> {
|
||||
const config = await getGithubConfig();
|
||||
if (!config) {
|
||||
throw new GithubPublishError(
|
||||
"GitHub publishing is not configured (missing token or repository)",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
|
||||
const safeUsername = input.username.replace(/[`*_~<>\[\]|]/g, "");
|
||||
const safeSubject = sanitize(input.subject, 200);
|
||||
const safeDescription = sanitize(input.description);
|
||||
|
||||
const categoryLabelMap: Record<string, string> = {
|
||||
BUG: "bug",
|
||||
FEATURE_REQUEST: "enhancement",
|
||||
QUESTION: "question",
|
||||
OTHER: "feedback",
|
||||
};
|
||||
const categoryLabel = categoryLabelMap[input.category] ?? "feedback";
|
||||
|
||||
let body = `**Reported by:** ${safeUsername}\n`;
|
||||
body += `**Category:** ${input.category}\n`;
|
||||
body += `**Date:** ${dateStr}\n\n`;
|
||||
body += `## Description\n\n${safeDescription}\n`;
|
||||
|
||||
if (input.metadata && typeof input.metadata === "object") {
|
||||
const meta = input.metadata as Record<string, unknown>;
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value == null) continue;
|
||||
const v = String(value).slice(0, 200);
|
||||
lines.push(`- **${key}:** ${v.replace(/[`<>]/g, "")}`);
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
body += `\n## Context\n\n${lines.join("\n")}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
body += `\n\n---\n*Created via HealthLog feedback*`;
|
||||
|
||||
const title = `[${categoryLabel}] ${safeSubject || `Feedback – ${dateStr}`}`;
|
||||
|
||||
const res = await safeFetch(
|
||||
`https://api.github.com/repos/${config.repo}/issues`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
labels: [categoryLabel, "user-reported"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text();
|
||||
// Strip the GitHub PAT before logging — GitHub error responses can echo
|
||||
// request headers in some failure modes, and Wide Events flow to Loki
|
||||
// unredacted. Keep the response shape useful for debugging without
|
||||
// leaking the credential.
|
||||
const sanitisedErr = errBody
|
||||
.replace(new RegExp(escapeRegex(config.token), "g"), "[REDACTED]")
|
||||
.slice(0, 500);
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + sanitisedErr);
|
||||
throw new GithubPublishError(
|
||||
`Failed to create GitHub issue (HTTP ${res.status})`,
|
||||
res.status === 401 || res.status === 403 ? res.status : 502,
|
||||
);
|
||||
}
|
||||
|
||||
const issue = (await res.json()) as { number: number; html_url: string };
|
||||
return { issueNumber: issue.number, issueUrl: issue.html_url };
|
||||
}
|
||||
@@ -25,7 +25,6 @@ const FULL_METRICS: AchievementMetrics = {
|
||||
passkeyLoginCount: 1000,
|
||||
passwordLoginCount: 1000,
|
||||
loginDayStreak: 1000,
|
||||
bugReportCount: 1000,
|
||||
moodEntryCount: 1000,
|
||||
moodDayStreak: 1000,
|
||||
moodImprovementHit: 1000,
|
||||
@@ -91,8 +90,8 @@ describe("gamification achievements", () => {
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("ships the expanded definition list (65 total — v1.4.18 + v1.16.1 care routine)", () => {
|
||||
expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(65);
|
||||
it("ships the expanded definition list (63 total — v1.4.18 + v1.16.1 care routine)", () => {
|
||||
expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(63);
|
||||
});
|
||||
|
||||
it("includes mood and hidden categories in the render order", () => {
|
||||
@@ -103,8 +102,8 @@ describe("gamification achievements", () => {
|
||||
it("evaluates unlocked achievements and points when all metrics maxed", () => {
|
||||
const result = evaluateAchievementsWithCompletionDates(FULL_METRICS, {});
|
||||
|
||||
expect(result.summary.unlockedCount).toBe(65);
|
||||
expect(result.summary.totalCount).toBe(65);
|
||||
expect(result.summary.unlockedCount).toBe(63);
|
||||
expect(result.summary.totalCount).toBe(63);
|
||||
expect(result.summary.nextAchievement).toBeNull();
|
||||
expect(result.summary.earnedPoints).toBe(result.summary.totalPoints);
|
||||
});
|
||||
|
||||
@@ -598,7 +598,6 @@ export async function buildAchievementsResult(
|
||||
in: [
|
||||
"auth.login.passkey",
|
||||
"auth.login.password",
|
||||
"bugreport.submit",
|
||||
// v1.4.18 — hidden Easter-egg triggers
|
||||
"doctor-report.generate",
|
||||
"doctor-report.pdf.generate",
|
||||
@@ -700,9 +699,6 @@ export async function buildAchievementsResult(
|
||||
const passwordLoginDates = auditEvents
|
||||
.filter((event) => event.action === "auth.login.password")
|
||||
.map((event) => event.createdAt);
|
||||
const bugReportDates = auditEvents
|
||||
.filter((event) => event.action === "bugreport.submit")
|
||||
.map((event) => event.createdAt);
|
||||
const loginDaySeries = getEventDaySeries([
|
||||
...passkeyLoginDates,
|
||||
...passwordLoginDates,
|
||||
@@ -800,7 +796,6 @@ export async function buildAchievementsResult(
|
||||
passkeyLoginCount: passkeyLoginDates.length,
|
||||
passwordLoginCount: passwordLoginDates.length,
|
||||
loginDayStreak: calculateLongestStreak(freeze(loginDaySeries.dayKeys)),
|
||||
bugReportCount: bugReportDates.length,
|
||||
...expansionValues,
|
||||
// v1.16.1 — care-routine metrics.
|
||||
missFreeDayStreak: calculateLongestStreak(freeze(missFreeSeries.dayKeys)),
|
||||
@@ -848,15 +843,6 @@ export async function buildAchievementsResult(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "bugReportCount") {
|
||||
const completedAt = findCountCompletionDate(
|
||||
bugReportDates,
|
||||
definition.target,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "overIntakeCount") {
|
||||
const completedAt = findCountCompletionDate(
|
||||
intakeIssueMetrics.overIntakeDates,
|
||||
|
||||
@@ -15,7 +15,6 @@ export type AchievementMetricKey =
|
||||
| "passkeyLoginCount"
|
||||
| "passwordLoginCount"
|
||||
| "loginDayStreak"
|
||||
| "bugReportCount"
|
||||
// v1.4.18 expansion ─ mood
|
||||
| "moodEntryCount"
|
||||
| "moodDayStreak"
|
||||
@@ -84,7 +83,7 @@ export interface AchievementDefinition {
|
||||
* v1.18.0 — module ownership for an achievement metric. Returns the
|
||||
* `ModuleKey` whose enable/disable state governs the badge, or `null`
|
||||
* when the badge belongs to a core domain (vitals, medications) or to
|
||||
* an account-wide capability (security, login, bug reports) that has no
|
||||
* an account-wide capability (security, login) that has no
|
||||
* toggleable module behind it.
|
||||
*
|
||||
* The string literals here intentionally mirror `MODULE_KEYS` in
|
||||
@@ -148,7 +147,6 @@ function categoryForMetric(metric: AchievementMetricKey): AchievementCategory {
|
||||
case "passwordLoginCount":
|
||||
return "security";
|
||||
case "loginDayStreak":
|
||||
case "bugReportCount":
|
||||
case "consistentMonthCount":
|
||||
case "entryDayStreak":
|
||||
case "weekendStreakCount":
|
||||
@@ -192,7 +190,6 @@ export interface AchievementMetrics {
|
||||
passkeyLoginCount: number;
|
||||
passwordLoginCount: number;
|
||||
loginDayStreak: number;
|
||||
bugReportCount: number;
|
||||
moodEntryCount: number;
|
||||
moodDayStreak: number;
|
||||
moodImprovementHit: number;
|
||||
@@ -386,16 +383,6 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
|
||||
tDescription: "achievements.badges.passwordLogin1.description",
|
||||
format: "count",
|
||||
}),
|
||||
define({
|
||||
id: "bugreport-1",
|
||||
metric: "bugReportCount",
|
||||
target: 1,
|
||||
points: 30,
|
||||
icon: "Bug",
|
||||
tTitle: "achievements.badges.bugReport1.title",
|
||||
tDescription: "achievements.badges.bugReport1.description",
|
||||
format: "count",
|
||||
}),
|
||||
define({
|
||||
id: "login-streak-7",
|
||||
metric: "loginDayStreak",
|
||||
@@ -668,17 +655,6 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
|
||||
format: "count",
|
||||
isHidden: true,
|
||||
}),
|
||||
define({
|
||||
id: "hidden-bug-buddy",
|
||||
metric: "bugReportCount",
|
||||
target: 5,
|
||||
points: 60,
|
||||
icon: "Bug",
|
||||
tTitle: "achievements.badges.hiddenBugBuddy.title",
|
||||
tDescription: "achievements.badges.hiddenBugBuddy.description",
|
||||
format: "count",
|
||||
isHidden: true,
|
||||
}),
|
||||
// ─── v1.16.1 ─ care routine (6) ─────────────────────────
|
||||
// Quiet, clinically meaningful milestones: therapy adherence without
|
||||
// a single auto-missed dose, sustained measurement consistency, a
|
||||
@@ -932,7 +908,7 @@ export function evaluateAchievementsWithCompletionDates(
|
||||
* 2. Hidden Easter-eggs always stay (the "?" placeholder is the
|
||||
* whole point).
|
||||
* 3. Achievements without a metric-data precondition (security,
|
||||
* bugreport, login-streaks) stay because the precondition is
|
||||
* login-streaks) stay because the precondition is
|
||||
* "the user has an account", which is implicit.
|
||||
*/
|
||||
export function applyDiscoveryFilter(
|
||||
@@ -977,7 +953,6 @@ function isEarnable(
|
||||
case "passkeyLoginCount":
|
||||
case "passwordLoginCount":
|
||||
case "loginDayStreak":
|
||||
case "bugReportCount":
|
||||
case "consistentMonthCount":
|
||||
case "entryDayStreak":
|
||||
case "weekendStreakCount":
|
||||
|
||||
@@ -41,6 +41,4 @@ export const settingsKeys = {
|
||||
shareLinks: () => ["share-links"] as const,
|
||||
|
||||
featureFlags: () => ["feature-flags"] as const,
|
||||
|
||||
bugreportStatus: () => ["bugreport", "status"] as const,
|
||||
};
|
||||
@@ -62,20 +62,6 @@ export const adminSettingsSchema = z
|
||||
)
|
||||
.optional(),
|
||||
glitchtipEnvironment: z.string().optional(),
|
||||
bugReportRepo: z
|
||||
.string()
|
||||
.refine(
|
||||
(v) => {
|
||||
const trimmed = v.trim();
|
||||
if (!trimmed) return true;
|
||||
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(trimmed);
|
||||
},
|
||||
{ message: "GitHub repository must be in owner/repo format" },
|
||||
)
|
||||
.optional(),
|
||||
bugReportToken: z.string().optional(),
|
||||
clearBugReportToken: z.boolean().optional(),
|
||||
bugReportEnabled: z.boolean().optional(),
|
||||
reminderLateMinutes: z.number().int().min(15).max(480).optional(),
|
||||
reminderMissedMinutes: z.number().int().min(30).max(720).optional(),
|
||||
moodLogGlobal: z.boolean().optional(),
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const feedbackCategoryEnum = z.enum([
|
||||
"BUG",
|
||||
"FEATURE_REQUEST",
|
||||
"QUESTION",
|
||||
"OTHER",
|
||||
]);
|
||||
|
||||
export const feedbackStatusEnum = z.enum([
|
||||
"OPEN",
|
||||
"ACKNOWLEDGED",
|
||||
"RESOLVED",
|
||||
"ARCHIVED",
|
||||
]);
|
||||
|
||||
// Strictly PNG / JPEG / WEBP / GIF data URLs. SVG intentionally excluded —
|
||||
// it can carry inline JS that would execute when the admin inbox renders the
|
||||
// preview.
|
||||
const SCREENSHOT_DATA_URL =
|
||||
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
|
||||
|
||||
export const createFeedbackSchema = z.object({
|
||||
category: feedbackCategoryEnum.default("BUG"),
|
||||
subject: z.string().min(3).max(200),
|
||||
description: z.string().min(10).max(5000),
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot too large (max 5 MB)")
|
||||
.regex(
|
||||
SCREENSHOT_DATA_URL,
|
||||
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
|
||||
)
|
||||
.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const updateFeedbackSchema = z.object({
|
||||
status: feedbackStatusEnum.optional(),
|
||||
adminNote: z.string().max(5000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type CreateFeedbackPayload = z.infer<typeof createFeedbackSchema>;
|
||||
export type UpdateFeedbackPayload = z.infer<typeof updateFeedbackSchema>;
|
||||
@@ -143,8 +143,6 @@ const LEGACY_ADMIN_ANCHORS: Record<string, string> = {
|
||||
"/admin/section-admin-umami": "/admin/integrations",
|
||||
"/admin/section-admin-glitchtip": "/admin/integrations",
|
||||
"/admin/section-admin-webpush": "/admin/integrations",
|
||||
"/admin/section-admin-bugreport": "/admin/integrations",
|
||||
"/admin/section-admin-feedback": "/admin/feedback",
|
||||
"/admin/section-admin-reminders": "/admin/reminders",
|
||||
"/admin/section-user-management": "/admin/users",
|
||||
"/admin/section-api-tokens": "/admin/api-tokens",
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
* - push_subscriptions (raw browser push endpoints)
|
||||
* - telegram_scheduled_deletions (queued message-delete tickets)
|
||||
*
|
||||
* Feedback rows are intentionally PRESERVED (T8 from v1.4.6 + admin
|
||||
* i18n copy now states this explicitly) — a wipe must not erase
|
||||
* user-submitted bug reports admins still need to triage.
|
||||
* AuditLog rows are intentionally PRESERVED — a wipe must not erase the
|
||||
* audit trail of the operation itself.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -58,7 +57,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
describe("DELETE /api/admin/data wipe scope", () => {
|
||||
it("clears notification channels, push subscriptions, telegram deletions; preserves Feedback + AuditLog", async () => {
|
||||
it("clears notification channels, push subscriptions, telegram deletions; preserves AuditLog", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// ── arrange ──
|
||||
@@ -105,16 +104,6 @@ describe("DELETE /api/admin/data wipe scope", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Seed a feedback row that MUST survive the wipe (per i18n copy).
|
||||
const feedbackRow = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
category: "BUG",
|
||||
subject: "survives wipe",
|
||||
description: "feedback rows are preserved",
|
||||
},
|
||||
});
|
||||
|
||||
// ── act: invoke the route handler exactly as Next.js would ──
|
||||
const { DELETE } = await import("@/app/api/admin/data/route");
|
||||
const req = new Request("http://localhost/api/admin/data", {
|
||||
@@ -141,13 +130,7 @@ describe("DELETE /api/admin/data wipe scope", () => {
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// ── assert: feedback row survives, AuditLog rows survive ──
|
||||
const feedbackAfter = await prisma.feedback.findUnique({
|
||||
where: { id: feedbackRow.id },
|
||||
});
|
||||
expect(feedbackAfter).not.toBeNull();
|
||||
expect(feedbackAfter?.subject).toBe("survives wipe");
|
||||
|
||||
// ── assert: AuditLog rows survive ──
|
||||
// The wipe writes admin.data.clear.start AND admin.data.clear audit
|
||||
// entries. They must outlive the operation (audit log is immune).
|
||||
const auditCount = await prisma.auditLog.count({
|
||||
|
||||
@@ -92,25 +92,6 @@ async function loginAs(userId: string) {
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
async function loginAsAdmin() {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
username: "cache-admin",
|
||||
email: "cache-admin@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.clear();
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return admin.id;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
@@ -240,26 +221,4 @@ describe("v1.4.34 IW-G — invalidation matrix across the major write surfaces",
|
||||
expect(caches.dashboardWidgets.get(PRIMARY_USER_ID)).toBeNull();
|
||||
expect(caches.dashboardWidgets.get(OTHER_USER_ID)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("admin app-settings PUT evicts the bugreportStatus singleton cache", async () => {
|
||||
await loginAsAdmin();
|
||||
|
||||
const { caches } = await import("@/lib/cache/server-cache");
|
||||
caches.bugreportStatus.set("singleton", { fake: "status" });
|
||||
expect(caches.bugreportStatus.get("singleton")).not.toBeNull();
|
||||
|
||||
const { PUT } = await import("@/app/api/admin/settings/route");
|
||||
const res = await PUT(
|
||||
new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ bugReportRepo: "owner/repo" }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The singleton bucket is gone — the next bugreport.status read
|
||||
// will rebuild from app_settings.
|
||||
expect(caches.bugreportStatus.get("singleton")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Deleting a user must wipe every personal-data row that references
|
||||
* them. The schema declares `onDelete: Cascade` for personal data and
|
||||
* `onDelete: SetNull` for audit-style rows (AuditLog, Feedback). This
|
||||
* `onDelete: SetNull` for audit-style rows (AuditLog). This
|
||||
* test verifies the actual database FKs match those declarations — a
|
||||
* missing CASCADE would leave orphan rows behind, violating GDPR.
|
||||
*/
|
||||
@@ -143,19 +143,11 @@ describe("user.delete cascades to all personal-data tables", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Audit logs / feedback are intentionally SetNull, not Cascade —
|
||||
// they survive deletion with a null userId for compliance triage.
|
||||
// Audit logs are intentionally SetNull, not Cascade — they survive
|
||||
// deletion with a null userId for compliance triage.
|
||||
const auditRow = await prisma.auditLog.create({
|
||||
data: { userId: user.id, action: "auth.login" },
|
||||
});
|
||||
const feedbackRow = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "test",
|
||||
},
|
||||
});
|
||||
|
||||
// ── act ──
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
@@ -200,10 +192,5 @@ describe("user.delete cascades to all personal-data tables", () => {
|
||||
where: { id: auditRow.id },
|
||||
});
|
||||
expect(auditAfter?.userId).toBeNull();
|
||||
|
||||
const feedbackAfter = await prisma.feedback.findUnique({
|
||||
where: { id: feedbackRow.id },
|
||||
});
|
||||
expect(feedbackAfter?.userId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,6 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
|
||||
"coach_usage",
|
||||
"data_backups",
|
||||
"devices",
|
||||
"feedback",
|
||||
"host_metrics",
|
||||
"idempotency_keys",
|
||||
"integration_statuses",
|
||||
|
||||
Reference in new issue
Block a user