mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
perf(i18n,shell): ship one locale and stop blocking the first paint
The client bundle carries only the active locale — resolved on the server and handed to the provider with no hydration flash, halving the dashboard route to under a megabyte parsed (288 KiB gzip). The auth shell renders the app chrome immediately instead of a spinner while the session resolves in parallel, the widgets request folds into the snapshot it duplicated, and insight pills prefetch their sub-pages — a first visit to a sub-page lands in about ninety milliseconds instead of nearly a second.
This commit is contained in:
1 parent
9445d89b76
commit
3b480b9885
284 files changed
+6058
-5398
No files matched your search
@@ -1735,6 +1735,31 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurements/restore:
|
||||
post:
|
||||
tags:
|
||||
- Measurements
|
||||
summary: Restore soft-deleted measurements
|
||||
description: Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo
|
||||
counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited
|
||||
`measurements:restore:<userId>`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the
|
||||
count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementsRestoreRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Restore processed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementsRestoreResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/sleep/night:
|
||||
get:
|
||||
tags:
|
||||
@@ -4229,6 +4254,20 @@ components:
|
||||
required:
|
||||
- ids
|
||||
description: 1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).
|
||||
MeasurementsRestoreRequest:
|
||||
type: object
|
||||
properties:
|
||||
ids:
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- ids
|
||||
description: 1..200 measurement ids, scoped to the caller. Forged / foreign / not-deleted ids are silently skipped (no
|
||||
existence leak).
|
||||
DeviceRegisterRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -7323,6 +7362,33 @@ components:
|
||||
required:
|
||||
- deleted
|
||||
additionalProperties: false
|
||||
MeasurementsRestoreResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MeasurementsRestoreResult"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementsRestoreResult:
|
||||
type: object
|
||||
properties:
|
||||
restored:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- restored
|
||||
additionalProperties: false
|
||||
SleepNightResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+32
-3
@@ -149,9 +149,38 @@ Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
|
||||
_and_ the user routinely sweeps. For most thresholds and counts, an
|
||||
`<Input type="number">` plus stepper buttons is faster and less
|
||||
fiddly. See §6 (Slider audit) for the migration path.
|
||||
- **Boolean toggle**: `<Switch>` for "applies immediately" settings (e.g.
|
||||
enable Telegram). `<Checkbox>` for "applies on Save" lists (e.g. select
|
||||
measurements to export).
|
||||
- **Boolean toggle**: `<Switch>` for settings. Exactly two persistence
|
||||
patterns are legal (see §2.2.1); pick one per card, never mix.
|
||||
`<Checkbox>` stays reserved for "applies on Save" _lists_ (e.g. select
|
||||
measurements to export) and for action parameters that are not
|
||||
persisted at all (export options).
|
||||
|
||||
#### 2.2.1 Settings persistence patterns
|
||||
|
||||
Every control under `/settings` follows exactly one of two save
|
||||
patterns. The pattern is a property of the **card**, not of the
|
||||
individual control:
|
||||
|
||||
1. **Immediate persist** — a standalone boolean that is the card's only
|
||||
mutable state. The `<Switch>` fires the mutation in
|
||||
`onCheckedChange`, disables itself while pending, updates
|
||||
optimistically with rollback, and toasts on success/error. No Save
|
||||
button anywhere on the card. Examples: hide-Coach, ntfy enable,
|
||||
cycle tracking, research mode, mood reminder, Coach nudges.
|
||||
2. **Draft + Save** — the card edits a multi-field payload (or a list
|
||||
the server PUTs whole). All controls — including `<Switch>` rows —
|
||||
mutate a local draft only; a visible Save button flushes the draft
|
||||
in one request and the card surfaces its dirty state. Examples:
|
||||
Coach preferences, Telegram (enable + bot token), provider fallback
|
||||
chain, dashboard layout, threshold editor.
|
||||
|
||||
A `<Switch>` inside a draft+Save card is legal **only** when the card
|
||||
shows the Save button; a card with both an auto-persisting switch and
|
||||
a Save-gated field is a bug. Every setting presented under `/settings`
|
||||
persists server-side. `localStorage` is reserved for device-local UI
|
||||
state that is not a setting (sidebar collapse, last-update-check
|
||||
timestamp, tour progress) — label such keys with a comment at the
|
||||
declaration site and never render them with Save/Saved affordances.
|
||||
|
||||
**Validation.** Inline messages below the field. Field gets
|
||||
`aria-invalid="true"` and a destructive border. Form-level errors land
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"undo": "Rückgängig",
|
||||
"edit": "Bearbeiten",
|
||||
"moreOptions": "Weitere Optionen",
|
||||
"create": "Erstellen",
|
||||
@@ -449,6 +450,9 @@
|
||||
"saveError": "Fehler beim Speichern",
|
||||
"duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.",
|
||||
"deleteError": "Fehler beim Löschen",
|
||||
"deletedToast": "Messwert gelöscht",
|
||||
"restoredToast": "Messwert wiederhergestellt",
|
||||
"restoreError": "Wiederherstellen fehlgeschlagen",
|
||||
"invalidValue": "Bitte einen gültigen Wert eingeben.",
|
||||
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
|
||||
"loginRequired": "Bitte anmelden, um Messwerte zu sehen.",
|
||||
@@ -547,6 +551,9 @@
|
||||
"formResetConfirmAction": "Zurücksetzen",
|
||||
"saveError": "Fehler beim Speichern",
|
||||
"deleteError": "Fehler beim Löschen",
|
||||
"deletedToast": "Eintrag gelöscht",
|
||||
"restoredToast": "Eintrag wiederhergestellt",
|
||||
"restoreError": "Wiederherstellen fehlgeschlagen",
|
||||
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
|
||||
"deleteConfirmTitle": "Eintrag löschen?",
|
||||
"deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.",
|
||||
@@ -806,6 +813,7 @@
|
||||
"loading": "Wird geladen…",
|
||||
"deleteCta": "Löschen",
|
||||
"deleteConfirm": "Eintrag löschen?",
|
||||
"deleteError": "Eintrag konnte nicht gelöscht werden",
|
||||
"errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen",
|
||||
"errorEntry": "Bitte ein Symptom auswählen",
|
||||
"categories": {
|
||||
@@ -1975,6 +1983,7 @@
|
||||
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
|
||||
"errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
|
||||
"errorNetwork": "Keine Internetverbindung — versuche es erneut, sobald du online bist.",
|
||||
"prefsSaveError": "Coach-Einstellungen konnten nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
"errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.",
|
||||
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
|
||||
"newChat": "Neuer Chat",
|
||||
@@ -1995,6 +2004,7 @@
|
||||
"failed": "Übernahme fehlgeschlagen. Bitte erneut versuchen."
|
||||
},
|
||||
"selfContextChipDismiss": "Frage verwerfen",
|
||||
"selfContextChipDismissError": "Frage konnte nicht ausgeblendet werden",
|
||||
"voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.",
|
||||
"threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.",
|
||||
"historyTitle": "Unterhaltungen",
|
||||
@@ -2085,6 +2095,7 @@
|
||||
"feedbackHelpful": "Hilfreich",
|
||||
"feedbackUnhelpful": "Nicht ganz",
|
||||
"feedbackThanks": "Danke für das Signal.",
|
||||
"feedbackError": "Feedback konnte nicht gespeichert werden",
|
||||
"dailyLimitTitle": "Heute-Limit erreicht",
|
||||
"dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
@@ -5641,6 +5652,8 @@
|
||||
"EMERGENCY": "Notfall"
|
||||
},
|
||||
"loadError": "Zyklusdaten konnten nicht geladen werden.",
|
||||
"saveError": "Zykluseintrag konnte nicht gespeichert werden.",
|
||||
"deleteError": "Eintrag konnte nicht gelöscht werden.",
|
||||
"bbt": {
|
||||
"title": "Basaltemperatur",
|
||||
"caption": "Deine Morgentemperatur über diesen Zyklus. Ein anhaltender Anstieg um etwa 0,2 °C folgt auf den Eisprung.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"undo": "Undo",
|
||||
"edit": "Edit",
|
||||
"moreOptions": "More options",
|
||||
"create": "Create",
|
||||
@@ -449,6 +450,9 @@
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
|
||||
"deleteError": "Error deleting",
|
||||
"deletedToast": "Measurement deleted",
|
||||
"restoredToast": "Measurement restored",
|
||||
"restoreError": "Restore failed",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
"loginRequired": "Please sign in to view measurements.",
|
||||
@@ -547,6 +551,9 @@
|
||||
"formResetConfirmAction": "Reset",
|
||||
"saveError": "Error saving",
|
||||
"deleteError": "Error deleting",
|
||||
"deletedToast": "Entry deleted",
|
||||
"restoredToast": "Entry restored",
|
||||
"restoreError": "Restore failed",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
"deleteConfirmTitle": "Delete entry?",
|
||||
"deleteConfirmDescription": "This mood entry will be permanently deleted.",
|
||||
@@ -806,6 +813,7 @@
|
||||
"loading": "Loading…",
|
||||
"deleteCta": "Delete",
|
||||
"deleteConfirm": "Delete this entry?",
|
||||
"deleteError": "Could not delete the entry",
|
||||
"errorSeverity": "Severity must be between 1 and 5",
|
||||
"errorEntry": "Pick a symptom",
|
||||
"categories": {
|
||||
@@ -1975,6 +1983,7 @@
|
||||
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.",
|
||||
"errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.",
|
||||
"errorNetwork": "No internet connection — try again once you're back online.",
|
||||
"prefsSaveError": "Couldn't save your Coach preferences. Please try again.",
|
||||
"errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.",
|
||||
"tagline": "Personal health coach, grounded in your data.",
|
||||
"newChat": "New chat",
|
||||
@@ -1995,6 +2004,7 @@
|
||||
"failed": "Could not add. Please try again."
|
||||
},
|
||||
"selfContextChipDismiss": "Dismiss question",
|
||||
"selfContextChipDismissError": "Could not dismiss the question",
|
||||
"voiceComingSoon": "Voice input arrives with the iOS app in v1.5.",
|
||||
"threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.",
|
||||
"historyTitle": "Conversations",
|
||||
@@ -2085,6 +2095,7 @@
|
||||
"feedbackHelpful": "Helpful",
|
||||
"feedbackUnhelpful": "Not quite",
|
||||
"feedbackThanks": "Thanks for the signal.",
|
||||
"feedbackError": "Could not save feedback",
|
||||
"dailyLimitTitle": "Daily limit reached",
|
||||
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
@@ -5641,6 +5652,8 @@
|
||||
"EMERGENCY": "Emergency"
|
||||
},
|
||||
"loadError": "Couldn't load your cycle data.",
|
||||
"saveError": "Couldn't save your cycle entry.",
|
||||
"deleteError": "Couldn't delete the entry.",
|
||||
"bbt": {
|
||||
"title": "Basal body temperature",
|
||||
"caption": "Your morning temperature across this cycle. A sustained rise of about 0.2 °C follows ovulation.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"undo": "Deshacer",
|
||||
"edit": "Editar",
|
||||
"moreOptions": "Más opciones",
|
||||
"create": "Crear",
|
||||
@@ -449,6 +450,9 @@
|
||||
"saveError": "Error al guardar",
|
||||
"duplicateTimestamp": "Ya existe una medición de este tipo en la marca de tiempo seleccionada.",
|
||||
"deleteError": "Error al eliminar",
|
||||
"deletedToast": "Medición eliminada",
|
||||
"restoredToast": "Medición restaurada",
|
||||
"restoreError": "Error al restaurar",
|
||||
"invalidValue": "Introduce un valor válido.",
|
||||
"invalidTimestamp": "Selecciona una fecha y hora válidas.",
|
||||
"loginRequired": "Inicia sesión para ver las mediciones.",
|
||||
@@ -547,6 +551,9 @@
|
||||
"formResetConfirmAction": "Restablecer",
|
||||
"saveError": "Error al guardar",
|
||||
"deleteError": "Error al eliminar",
|
||||
"deletedToast": "Entrada eliminada",
|
||||
"restoredToast": "Entrada restaurada",
|
||||
"restoreError": "Error al restaurar",
|
||||
"invalidTimestamp": "Selecciona una fecha y hora válidas.",
|
||||
"deleteConfirmTitle": "¿Eliminar entrada?",
|
||||
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.",
|
||||
@@ -806,6 +813,7 @@
|
||||
"loading": "Cargando…",
|
||||
"deleteCta": "Eliminar",
|
||||
"deleteConfirm": "¿Eliminar esta entrada?",
|
||||
"deleteError": "No se pudo eliminar la entrada",
|
||||
"errorSeverity": "La gravedad debe estar entre 1 y 5",
|
||||
"errorEntry": "Elige un síntoma",
|
||||
"categories": {
|
||||
@@ -1975,6 +1983,7 @@
|
||||
"errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).",
|
||||
"errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.",
|
||||
"errorNetwork": "Sin conexión a Internet — inténtalo de nuevo cuando vuelvas a estar en línea.",
|
||||
"prefsSaveError": "No se pudieron guardar tus preferencias del Coach. Inténtalo de nuevo.",
|
||||
"errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.",
|
||||
"tagline": "Coach personal de salud, apoyado en tus datos.",
|
||||
"newChat": "Nueva conversación",
|
||||
@@ -1995,6 +2004,7 @@
|
||||
"failed": "No se pudo añadir. Inténtalo de nuevo."
|
||||
},
|
||||
"selfContextChipDismiss": "Descartar pregunta",
|
||||
"selfContextChipDismissError": "No se pudo descartar la pregunta",
|
||||
"voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.",
|
||||
"threadEmpty": "Pregúntame algo sobre tus tendencias, medicamentos o mediciones para empezar.",
|
||||
"historyTitle": "Conversaciones",
|
||||
@@ -2083,6 +2093,7 @@
|
||||
"feedbackHelpful": "Útil",
|
||||
"feedbackUnhelpful": "Poco útil",
|
||||
"feedbackThanks": "Gracias por la indicación.",
|
||||
"feedbackError": "No se pudo guardar el comentario",
|
||||
"dailyLimitTitle": "Límite diario alcanzado",
|
||||
"dailyLimitBody": "Límite diario alcanzado; reinicio a las 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Proveedor con límite de tasa",
|
||||
@@ -5641,6 +5652,8 @@
|
||||
"EMERGENCY": "Emergencia"
|
||||
},
|
||||
"loadError": "No se pudieron cargar tus datos del ciclo.",
|
||||
"saveError": "No se pudo guardar tu registro del ciclo.",
|
||||
"deleteError": "No se pudo eliminar la entrada.",
|
||||
"bbt": {
|
||||
"title": "Temperatura basal",
|
||||
"caption": "Tu temperatura matutina a lo largo de este ciclo. Una subida sostenida de unos 0,2 °C sigue a la ovulación.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"undo": "Annuler",
|
||||
"edit": "Modifier",
|
||||
"moreOptions": "Plus d'options",
|
||||
"create": "Créer",
|
||||
@@ -449,6 +450,9 @@
|
||||
"saveError": "Erreur lors de l’enregistrement",
|
||||
"duplicateTimestamp": "Une mesure de ce type existe déjà à cet horodatage.",
|
||||
"deleteError": "Erreur lors de la suppression",
|
||||
"deletedToast": "Mesure supprimée",
|
||||
"restoredToast": "Mesure restaurée",
|
||||
"restoreError": "Échec de la restauration",
|
||||
"invalidValue": "Saisis une valeur valide.",
|
||||
"invalidTimestamp": "Choisis une date et une heure valides.",
|
||||
"loginRequired": "Connecte-toi pour voir tes mesures.",
|
||||
@@ -547,6 +551,9 @@
|
||||
"formResetConfirmAction": "Réinitialiser",
|
||||
"saveError": "Erreur lors de l’enregistrement",
|
||||
"deleteError": "Erreur lors de la suppression",
|
||||
"deletedToast": "Entrée supprimée",
|
||||
"restoredToast": "Entrée restaurée",
|
||||
"restoreError": "Échec de la restauration",
|
||||
"invalidTimestamp": "Choisis une date et une heure valides.",
|
||||
"deleteConfirmTitle": "Supprimer l’entrée ?",
|
||||
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.",
|
||||
@@ -806,6 +813,7 @@
|
||||
"loading": "Chargement…",
|
||||
"deleteCta": "Supprimer",
|
||||
"deleteConfirm": "Supprimer cette entrée ?",
|
||||
"deleteError": "Impossible de supprimer l'entrée",
|
||||
"errorSeverity": "La sévérité doit être entre 1 et 5",
|
||||
"errorEntry": "Choisissez un symptôme",
|
||||
"categories": {
|
||||
@@ -1975,6 +1983,7 @@
|
||||
"errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).",
|
||||
"errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.",
|
||||
"errorNetwork": "Pas de connexion Internet — réessaie une fois reconnecté.",
|
||||
"prefsSaveError": "Impossible d’enregistrer vos préférences du Coach. Réessayez.",
|
||||
"errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.",
|
||||
"tagline": "Coach personnel de santé, ancré sur tes données.",
|
||||
"newChat": "Nouvelle conversation",
|
||||
@@ -1995,6 +2004,7 @@
|
||||
"failed": "Échec de l’ajout. Veuillez réessayer."
|
||||
},
|
||||
"selfContextChipDismiss": "Ignorer la question",
|
||||
"selfContextChipDismissError": "Impossible de masquer la question",
|
||||
"voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.",
|
||||
"threadEmpty": "Pose une question sur tes tendances, médicaments ou mesures pour démarrer.",
|
||||
"historyTitle": "Conversations",
|
||||
@@ -2083,6 +2093,7 @@
|
||||
"feedbackHelpful": "Utile",
|
||||
"feedbackUnhelpful": "Peu utile",
|
||||
"feedbackThanks": "Merci pour l’indication.",
|
||||
"feedbackError": "Impossible d'enregistrer le retour",
|
||||
"dailyLimitTitle": "Limite quotidienne atteinte",
|
||||
"dailyLimitBody": "Limite quotidienne atteinte ; réinitialisation à 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Fournisseur limité en débit",
|
||||
@@ -5641,6 +5652,8 @@
|
||||
"EMERGENCY": "Urgence"
|
||||
},
|
||||
"loadError": "Impossible de charger vos données de cycle.",
|
||||
"saveError": "Impossible d’enregistrer votre saisie de cycle.",
|
||||
"deleteError": "Impossible de supprimer l’entrée.",
|
||||
"bbt": {
|
||||
"title": "Température basale",
|
||||
"caption": "Votre température matinale sur ce cycle. Une hausse durable d'environ 0,2 °C suit l'ovulation.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"undo": "Annulla",
|
||||
"edit": "Modifica",
|
||||
"moreOptions": "Altre opzioni",
|
||||
"create": "Crea",
|
||||
@@ -449,6 +450,9 @@
|
||||
"saveError": "Errore durante il salvataggio",
|
||||
"duplicateTimestamp": "Esiste già una misurazione di questo tipo a questa marca temporale.",
|
||||
"deleteError": "Errore durante l’eliminazione",
|
||||
"deletedToast": "Misurazione eliminata",
|
||||
"restoredToast": "Misurazione ripristinata",
|
||||
"restoreError": "Ripristino non riuscito",
|
||||
"invalidValue": "Inserisci un valore valido.",
|
||||
"invalidTimestamp": "Scegli una data e un’ora valide.",
|
||||
"loginRequired": "Accedi per vedere le misurazioni.",
|
||||
@@ -547,6 +551,9 @@
|
||||
"formResetConfirmAction": "Reimposta",
|
||||
"saveError": "Errore durante il salvataggio",
|
||||
"deleteError": "Errore durante l’eliminazione",
|
||||
"deletedToast": "Voce eliminata",
|
||||
"restoredToast": "Voce ripristinata",
|
||||
"restoreError": "Ripristino non riuscito",
|
||||
"invalidTimestamp": "Scegli una data e un’ora valide.",
|
||||
"deleteConfirmTitle": "Eliminare la voce?",
|
||||
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.",
|
||||
@@ -806,6 +813,7 @@
|
||||
"loading": "Caricamento…",
|
||||
"deleteCta": "Elimina",
|
||||
"deleteConfirm": "Eliminare questa voce?",
|
||||
"deleteError": "Impossibile eliminare la voce",
|
||||
"errorSeverity": "La gravità deve essere tra 1 e 5",
|
||||
"errorEntry": "Scegli un sintomo",
|
||||
"categories": {
|
||||
@@ -1975,6 +1983,7 @@
|
||||
"errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).",
|
||||
"errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.",
|
||||
"errorNetwork": "Nessuna connessione Internet — riprova quando sei di nuovo online.",
|
||||
"prefsSaveError": "Impossibile salvare le preferenze del Coach. Riprova.",
|
||||
"errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.",
|
||||
"tagline": "Coach personale di salute, basato sui tuoi dati.",
|
||||
"newChat": "Nuova chat",
|
||||
@@ -1995,6 +2004,7 @@
|
||||
"failed": "Aggiunta non riuscita. Riprova."
|
||||
},
|
||||
"selfContextChipDismiss": "Ignora la domanda",
|
||||
"selfContextChipDismissError": "Impossibile ignorare la domanda",
|
||||
"voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.",
|
||||
"threadEmpty": "Chiedimi qualcosa sui tuoi trend, farmaci o misurazioni per iniziare.",
|
||||
"historyTitle": "Conversazioni",
|
||||
@@ -2083,6 +2093,7 @@
|
||||
"feedbackHelpful": "Utile",
|
||||
"feedbackUnhelpful": "Poco utile",
|
||||
"feedbackThanks": "Grazie per il segnale.",
|
||||
"feedbackError": "Impossibile salvare il feedback",
|
||||
"dailyLimitTitle": "Limite giornaliero raggiunto",
|
||||
"dailyLimitBody": "Limite giornaliero raggiunto; reset alle 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Provider con rate-limit",
|
||||
@@ -5641,6 +5652,8 @@
|
||||
"EMERGENCY": "Emergenza"
|
||||
},
|
||||
"loadError": "Impossibile caricare i dati del ciclo.",
|
||||
"saveError": "Impossibile salvare la voce del ciclo.",
|
||||
"deleteError": "Impossibile eliminare la voce.",
|
||||
"bbt": {
|
||||
"title": "Temperatura basale",
|
||||
"caption": "La tua temperatura mattutina in questo ciclo. Un rialzo prolungato di circa 0,2 °C segue l'ovulazione.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj",
|
||||
"delete": "Usuń",
|
||||
"undo": "Cofnij",
|
||||
"edit": "Edytuj",
|
||||
"moreOptions": "Więcej opcji",
|
||||
"create": "Utwórz",
|
||||
@@ -449,6 +450,9 @@
|
||||
"saveError": "Błąd zapisu",
|
||||
"duplicateTimestamp": "Pomiar tego typu już istnieje dla wybranej daty.",
|
||||
"deleteError": "Błąd usuwania",
|
||||
"deletedToast": "Pomiar usunięty",
|
||||
"restoredToast": "Pomiar przywrócony",
|
||||
"restoreError": "Przywracanie nie powiodło się",
|
||||
"invalidValue": "Wprowadź poprawną wartość.",
|
||||
"invalidTimestamp": "Wybierz poprawną datę i godzinę.",
|
||||
"loginRequired": "Zaloguj się, aby zobaczyć pomiary.",
|
||||
@@ -547,6 +551,9 @@
|
||||
"formResetConfirmAction": "Resetuj",
|
||||
"saveError": "Błąd zapisu",
|
||||
"deleteError": "Błąd usuwania",
|
||||
"deletedToast": "Wpis usunięty",
|
||||
"restoredToast": "Wpis przywrócony",
|
||||
"restoreError": "Przywracanie nie powiodło się",
|
||||
"invalidTimestamp": "Wybierz poprawną datę i godzinę.",
|
||||
"deleteConfirmTitle": "Usunąć wpis?",
|
||||
"deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.",
|
||||
@@ -806,6 +813,7 @@
|
||||
"loading": "Ładowanie…",
|
||||
"deleteCta": "Usuń",
|
||||
"deleteConfirm": "Usunąć ten wpis?",
|
||||
"deleteError": "Nie udało się usunąć wpisu",
|
||||
"errorSeverity": "Nasilenie musi mieścić się w zakresie 1–5",
|
||||
"errorEntry": "Wybierz objaw",
|
||||
"categories": {
|
||||
@@ -1975,6 +1983,7 @@
|
||||
"errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).",
|
||||
"errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.",
|
||||
"errorNetwork": "Brak połączenia z Internetem — spróbuj ponownie, gdy wrócisz online.",
|
||||
"prefsSaveError": "Nie udało się zapisać ustawień Coacha. Spróbuj ponownie.",
|
||||
"errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.",
|
||||
"tagline": "Osobisty coach zdrowia, oparty na twoich danych.",
|
||||
"newChat": "Nowa rozmowa",
|
||||
@@ -1995,6 +2004,7 @@
|
||||
"failed": "Nie udało się dodać. Spróbuj ponownie."
|
||||
},
|
||||
"selfContextChipDismiss": "Odrzuć pytanie",
|
||||
"selfContextChipDismissError": "Nie udało się odrzucić pytania",
|
||||
"voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.",
|
||||
"threadEmpty": "Zapytaj o swoje trendy, leki lub pomiary, aby zacząć.",
|
||||
"historyTitle": "Rozmowy",
|
||||
@@ -2083,6 +2093,7 @@
|
||||
"feedbackHelpful": "Przydatne",
|
||||
"feedbackUnhelpful": "Mało przydatne",
|
||||
"feedbackThanks": "Dzięki za sygnał.",
|
||||
"feedbackError": "Nie udało się zapisać opinii",
|
||||
"dailyLimitTitle": "Osiągnięto dzienny limit",
|
||||
"dailyLimitBody": "Osiągnięto dzienny limit; reset o 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Dostawca z ograniczeniem",
|
||||
@@ -5641,6 +5652,8 @@
|
||||
"EMERGENCY": "Awaryjna"
|
||||
},
|
||||
"loadError": "Nie udało się wczytać danych cyklu.",
|
||||
"saveError": "Nie udało się zapisać wpisu cyklu.",
|
||||
"deleteError": "Nie udało się usunąć wpisu.",
|
||||
"bbt": {
|
||||
"title": "Podstawowa temperatura ciała",
|
||||
"caption": "Twoja poranna temperatura w tym cyklu. Trwały wzrost o około 0,2 °C następuje po owulacji.",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { join } from "node:path";
|
||||
* thick `/api/analytics` envelope; every per-type tile waited on the
|
||||
* heavy fan-out before paint. Mood and medication tiles arrived first
|
||||
* (separate routes) and then every other tile arrived as one burst,
|
||||
* which Marc reported as "etwas nervig" in the v1.4.39.1 post-deploy
|
||||
* which the maintainer reported as "etwas nervig" in the v1.4.39.1 post-deploy
|
||||
* trace.
|
||||
*
|
||||
* Post-fix the dashboard mounts TWO `useAnalyticsQuery` calls in
|
||||
|
||||
@@ -70,19 +70,19 @@ describe("GET /api/admin/audit-log — extended filters", () => {
|
||||
});
|
||||
|
||||
it("filters by `actor` against userId OR user.username", async () => {
|
||||
await GET(req("actor=marc"));
|
||||
await GET(req("actor=testuser"));
|
||||
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
|
||||
const where = call.where as Record<string, unknown>;
|
||||
// Expect an OR clause covering userId + user.username (contains)
|
||||
expect(where.OR).toBeDefined();
|
||||
const or = where.OR as Array<Record<string, unknown>>;
|
||||
const matchesUserId = or.some((c) => c.userId === "marc");
|
||||
const matchesUserId = or.some((c) => c.userId === "testuser");
|
||||
const matchesUsername = or.some(
|
||||
(c) =>
|
||||
typeof c.user === "object" &&
|
||||
c.user !== null &&
|
||||
// user: { username: { contains: "marc", mode: "insensitive" } }
|
||||
JSON.stringify(c).toLowerCase().includes("marc"),
|
||||
// user: { username: { contains: "testuser", mode: "insensitive" } }
|
||||
JSON.stringify(c).toLowerCase().includes("testuser"),
|
||||
);
|
||||
expect(matchesUserId).toBe(true);
|
||||
expect(matchesUsername).toBe(true);
|
||||
|
||||
@@ -60,7 +60,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const ADMIN_SESSION = {
|
||||
session: { id: "sess-admin", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "marc", role: "ADMIN" as const },
|
||||
user: { id: "admin-1", username: "testuser", role: "ADMIN" as const },
|
||||
};
|
||||
const USER_SESSION = {
|
||||
session: { id: "sess-user", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
|
||||
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
|
||||
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import { prisma } from "@/lib/db";
|
||||
|
||||
const ADMIN_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "marc", role: "ADMIN" as const },
|
||||
user: { id: "admin-1", username: "testuser", role: "ADMIN" as const },
|
||||
};
|
||||
const USER_OK = {
|
||||
session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("PUT /api/admin/users/[id]", () => {
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
PUT(jsonReq({ username: "testuser" }), params("u1")),
|
||||
).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("PUT /api/admin/users/[id]", () => {
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
PUT(jsonReq({ username: "testuser" }), params("u1")),
|
||||
).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ describe("PUT /api/admin/users/[id]", () => {
|
||||
|
||||
it("returns 404 when target user does not exist", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await PUT(jsonReq({ username: "marc" }), params("u-missing"));
|
||||
const res = await PUT(jsonReq({ username: "testuser" }), params("u-missing"));
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
|
||||
_count: { passkeys: 2 },
|
||||
} as never);
|
||||
|
||||
const res = await POST(makeRequest({ identifier: "marc" }));
|
||||
const res = await POST(makeRequest({ identifier: "testuser" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
@@ -111,7 +111,7 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
|
||||
ipAddress: "203.0.113.1",
|
||||
details: {
|
||||
branch: "passkey_only",
|
||||
identifier_hash: expectedHash("marc"),
|
||||
identifier_hash: expectedHash("testuser"),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -123,13 +123,13 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
|
||||
_count: { passkeys: 0 },
|
||||
} as never);
|
||||
|
||||
const res = await POST(makeRequest({ identifier: "marc@example.com" }));
|
||||
const res = await POST(makeRequest({ identifier: "user@example.com" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const args = vi.mocked(auditLog).mock.calls[0];
|
||||
expect(args[1]?.details).toEqual({
|
||||
branch: "email_fallback",
|
||||
identifier_hash: expectedHash("marc@example.com"),
|
||||
identifier_hash: expectedHash("user@example.com"),
|
||||
});
|
||||
expect(args[1]?.userId).toBe("user-2");
|
||||
});
|
||||
|
||||
@@ -75,8 +75,8 @@ import { createSession } from "@/lib/auth/session";
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
username: "testuser",
|
||||
email: "user@example.com",
|
||||
passwordHash: "$argon2id$...",
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@ function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "marc@example.com",
|
||||
email: "user@example.com",
|
||||
password: "supersecret",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(): NextRequest {
|
||||
|
||||
@@ -35,7 +35,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(deviceIdHeader?: string): NextRequest {
|
||||
|
||||
@@ -47,7 +47,7 @@ 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: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
|
||||
@@ -39,7 +39,7 @@ import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-pre
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPut(body: unknown): Request {
|
||||
@@ -69,7 +69,7 @@ describe("GET /api/auth/me/doctor-report-prefs", () => {
|
||||
data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
|
||||
};
|
||||
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
|
||||
expect(env.data.mood).toBe(false); // privacy default per Marc
|
||||
expect(env.data.mood).toBe(false); // privacy default per the maintainer
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
});
|
||||
|
||||
// ── Case 4 — unauthenticated cross-user request ────────────────────
|
||||
// Marc's spec mentions "cross-user 404" — the route layer returns 401
|
||||
// the maintainer's spec mentions "cross-user 404" — the route layer returns 401
|
||||
// when there is no session at all, which is the only "you are not the
|
||||
// right user" surface available before requireAuth() resolves an
|
||||
// identity. `prisma.user.update` must NOT fire in this case.
|
||||
|
||||
@@ -48,7 +48,7 @@ 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: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
|
||||
@@ -48,7 +48,7 @@ import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPost(body: unknown): Request {
|
||||
|
||||
@@ -37,7 +37,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPut(body: unknown): Request {
|
||||
|
||||
@@ -38,7 +38,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkReq(body: unknown): NextRequest {
|
||||
|
||||
@@ -46,7 +46,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
|
||||
@@ -60,8 +60,8 @@ import {
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
username: "testuser",
|
||||
email: "user@example.com",
|
||||
};
|
||||
|
||||
function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
|
||||
@@ -41,7 +41,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(body: unknown): NextRequest {
|
||||
|
||||
@@ -41,7 +41,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -65,7 +65,7 @@ const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
@@ -134,7 +134,7 @@ describe("GET /api/dashboard/summary", () => {
|
||||
metrics: Array<{ id: string; kind: string; sparkline: number[] }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.greeting.salutation).toBe("Hi, marc");
|
||||
expect(body.data.greeting.salutation).toBe("Hi, testuser");
|
||||
expect(body.data.streak.currentDays).toBe(0);
|
||||
expect(body.data.compliance.scheduledToday).toBe(0);
|
||||
// REG-11 (v1.4.44): weight is the only always-emitted base metric.
|
||||
|
||||
@@ -48,7 +48,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(): NextRequest {
|
||||
|
||||
@@ -59,7 +59,7 @@ 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: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(body: unknown): NextRequest {
|
||||
|
||||
@@ -72,7 +72,7 @@ function setEmptyDataMocks() {
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Returns one boolean per toggleable section so the dialog can hide
|
||||
* checkboxes for data types that have zero rows in the user-selected
|
||||
* date range (Marc's "hide-when-empty" rule). Pure presence check —
|
||||
* date range (the maintainer's "hide-when-empty" rule). Pure presence check —
|
||||
* `count` queries on small indexed tables — so this is cheap enough to
|
||||
* run on every date-range change in the dialog without driving cost.
|
||||
*
|
||||
|
||||
@@ -36,7 +36,7 @@ import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(): NextRequest {
|
||||
|
||||
@@ -43,7 +43,7 @@ const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
createdAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
heightCm: null,
|
||||
|
||||
@@ -43,7 +43,7 @@ const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
createdAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
heightCm: null,
|
||||
|
||||
@@ -37,7 +37,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const OWNER_SESSION = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
const OTHER_SESSION = {
|
||||
session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -45,7 +45,7 @@ const OTHER_SESSION = {
|
||||
};
|
||||
const ADMIN_SESSION = {
|
||||
session: { id: "sess-a", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "marc-admin", role: "ADMIN" as const },
|
||||
user: { id: "admin-1", username: "test-admin", role: "ADMIN" as const },
|
||||
};
|
||||
|
||||
const NOW = new Date("2026-05-15T10:00:00.000Z");
|
||||
|
||||
@@ -58,7 +58,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -45,7 +45,7 @@ import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -41,7 +41,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -97,7 +97,7 @@ const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-comp-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getLocalDateParts } from "@/lib/timezone";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
discoverCorrelations,
|
||||
@@ -39,7 +39,7 @@ const WINDOW_DAYS = 180;
|
||||
|
||||
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
|
||||
function tzDayKey(at: Date, tz: string): string {
|
||||
const { year, month, day } = getLocalDateParts(at, tz);
|
||||
const { year, month, day } = wallClockInTz(at, tz);
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const dd = String(day).padStart(2, "0");
|
||||
return `${year}-${mm}-${dd}`;
|
||||
|
||||
@@ -64,7 +64,7 @@ import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -54,7 +54,7 @@ import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -268,7 +268,7 @@ describe("POST /api/insights/generate — provider error mapping", () => {
|
||||
});
|
||||
|
||||
// v1.4.16 A7.1: rate limit raised from 2 → 10/h, env-configurable.
|
||||
// the maintainer reported the previous 2/h was too aggressive when iterating on
|
||||
// The maintainer reported the previous 2/h was too aggressive when iterating on
|
||||
// settings. The 10/h ceiling is the new default; the env override lets
|
||||
// operators on a tight LLM budget dial it back without a rebuild.
|
||||
describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ import { generateMetricStatus } from "@/lib/insights/metric-status";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -46,7 +46,7 @@ import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -41,7 +41,7 @@ import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -85,7 +85,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_USER = {
|
||||
id: "user-targets-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
heightCm: 180,
|
||||
|
||||
@@ -37,7 +37,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { MEASUREMENT_CATEGORIES } from "@/lib/measurements/categories";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(): NextRequest {
|
||||
|
||||
@@ -36,7 +36,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function putRequest(id: string, body: Record<string, unknown>): NextRequest {
|
||||
|
||||
@@ -350,7 +350,7 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
|
||||
});
|
||||
|
||||
it("source=rollup folds the partition inline and re-reads when the rollup is under-converged (v1.4.39.2)", async () => {
|
||||
// Marc's pre-fix production trace: 30-day BP_SYS window returned
|
||||
// The maintainer's pre-fix production trace: 30-day BP_SYS window returned
|
||||
// `total: 2` from the rollup branch even though `measurements`
|
||||
// carried ~30 distinct days. The chart tripped its `< 3 daily
|
||||
// points` empty-state because the route short-circuited on
|
||||
|
||||
@@ -60,7 +60,7 @@ import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* v1.16.4 — POST /api/measurements/restore.
|
||||
*
|
||||
* Asserts the ownership-scoped un-tombstone contract that backs the
|
||||
* delete-Undo toast: only owned, currently-tombstoned rows are touched;
|
||||
* a forged / foreign / never-deleted id is a silent no-op (no 404
|
||||
* existence leak); the rollup recompute collapses to the unique
|
||||
* `(type, day)` set; the >200-id cap returns 422.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: 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(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
return {
|
||||
// keep the real collapse so the test exercises the actual fan-out logic
|
||||
collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement: 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 { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
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/measurements/restore", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined);
|
||||
vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/restore", () => {
|
||||
it("un-tombstones only owned, tombstoned rows and returns that count", async () => {
|
||||
// Caller asked for 3 ids but only 2 are owned + tombstoned.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["m1", "m2", "foreign"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { restored: number } };
|
||||
expect(body.data.restored).toBe(2);
|
||||
|
||||
// updateMany is ownership-scoped + only touches tombstones — a
|
||||
// forged / live id never 404s, it's just excluded from the count.
|
||||
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
|
||||
expect(call.where).toMatchObject({
|
||||
id: { in: ["m1", "m2", "foreign"] },
|
||||
userId: "user-1",
|
||||
deletedAt: { not: null },
|
||||
});
|
||||
expect(call.data).toMatchObject({
|
||||
deletedAt: null,
|
||||
syncVersion: { increment: 1 },
|
||||
});
|
||||
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-02T09:00:00Z") },
|
||||
{ type: "PULSE", measuredAt: new Date("2026-01-01T10:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 4,
|
||||
} as never);
|
||||
|
||||
await POST(postReq({ ids: ["a", "b", "c", "d"] }));
|
||||
|
||||
// 4 rows in, 3 distinct (type, day) recomputes out.
|
||||
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("is a no-op (restored: 0) when no id is owned + tombstoned — no rollup, no invalidate", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["foreign-1", "live-1"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { restored: number } };
|
||||
expect(body.data.restored).toBe(0);
|
||||
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 200-id cap with 422", async () => {
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `m${i}`);
|
||||
const res = await POST(postReq({ ids }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty id array with 422", async () => {
|
||||
const res = await POST(postReq({ ids: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("429s when the rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(postReq({ ids: ["m1"] }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* `POST /api/measurements/restore` — un-tombstone for the management
|
||||
* list's delete-Undo affordance (v1.16.4).
|
||||
*
|
||||
* Body:
|
||||
* { ids: string[] } — 1..200 measurement ids, scoped to the caller.
|
||||
*
|
||||
* Clears `deletedAt` (+ bumps `syncVersion`) on every owned,
|
||||
* currently-tombstoned row in one `updateMany`, so the row re-surfaces
|
||||
* in normal reads and the `/api/sync/changes` delta feed carries it as
|
||||
* an upsert to paired clients. A forged / foreign / not-deleted id is a
|
||||
* silent no-op (never a 404 existence leak): the mutation `where` pins
|
||||
* `userId` so it only ever touches the caller's rows.
|
||||
*
|
||||
* Response (200):
|
||||
* { restored: <number of rows un-tombstoned> }
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
const RATE_LIMIT_MAX = 60;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const restoreSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
|
||||
|
||||
async function postRestore(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`measurements:restore:${user.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many restore requests, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = restoreSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "measurement.restore.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { ids } = parsed.data;
|
||||
|
||||
// Read the soon-to-be-restored rows first so we know which
|
||||
// `(type, day)` buckets need a rollup recompute and which types need
|
||||
// a status-insight cache drop. Ownership-scoped + `deletedAt != null`
|
||||
// so a forged / foreign / never-deleted id is excluded here exactly as
|
||||
// it is from the mutation below — no existence leak.
|
||||
const affected = await prisma.measurement.findMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
|
||||
select: { type: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Un-tombstone in one statement. The `where` pins `userId` so a forged
|
||||
// id for another user is silently a no-op; `deletedAt: { not: null }`
|
||||
// keeps the count honest (a live row doesn't re-count).
|
||||
const { count } = await prisma.measurement.updateMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
|
||||
data: { deletedAt: null, syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await auditLog("measurement.restore", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.restore" },
|
||||
meta: { count },
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
// Bust per-user analytics + achievements + workouts caches so
|
||||
// subsequent reads reflect the restored rows.
|
||||
invalidateUserMeasurements(user.id);
|
||||
|
||||
// Collapse the restored rows to the unique `(type, day)` set BEFORE
|
||||
// recomputing — mirrors the bulk-delete path. Best-effort: a
|
||||
// populator hiccup never fails the user's restore.
|
||||
const keys = collapseToTypeDayKeys(
|
||||
affected.map((row) => ({
|
||||
type: row.type as MeasurementType,
|
||||
measuredAt: row.measuredAt,
|
||||
})),
|
||||
);
|
||||
try {
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[measurements] restore rollup recompute failed", err);
|
||||
}
|
||||
|
||||
// Drop the cached per-metric assessment rows the restore dirties so
|
||||
// the next mount / nightly warm pass regenerates against the
|
||||
// restored history. Fire-and-forget: never blocks the restore.
|
||||
const affectedTypes = Array.from(new Set(keys.map((k) => k.type)));
|
||||
if (affectedTypes.length > 0) {
|
||||
invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => {
|
||||
console.warn(
|
||||
"[measurements] restore status-insight invalidate failed",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ restored: count });
|
||||
}
|
||||
@@ -319,7 +319,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// `GET /api/measurements?type=…&aggregate=daily&limit=5000` requests
|
||||
// (BP_SYS / BP_DIA / WEIGHT). The legacy `date_trunc('day', …)`
|
||||
// path scanned the measurements table each time — a full year window
|
||||
// on Marc's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
|
||||
// on the maintainer's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
|
||||
// the persistent `measurement_rollups` DAY buckets drops each call
|
||||
// to a small indexed read against the ~5 k-row rollup table.
|
||||
//
|
||||
@@ -398,7 +398,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// window) and finishes well inside the request budget.
|
||||
//
|
||||
// The probe runs only on suspicious rollups so the covered-tenant
|
||||
// happy path stays a single indexed read. Marc's tenant on a 30-day
|
||||
// happy path stays a single indexed read. The maintainer's tenant on a 30-day
|
||||
// BP_SYS window pre-fix returned 2 rollup rows and hit `total: 2`
|
||||
// in the live trace; post-fix the probe finds ~30 distinct days in
|
||||
// `measurements`, folds them, and the chart paints all 30.
|
||||
|
||||
@@ -55,7 +55,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(query: string): NextRequest {
|
||||
|
||||
@@ -39,7 +39,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -73,7 +73,7 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { cached } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
// UTC keeps the half-open day slice (`[dayStart, dayEnd)`) and the
|
||||
@@ -84,7 +84,7 @@ const TZ = "UTC";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, timezone: TZ },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, timezone: TZ },
|
||||
};
|
||||
|
||||
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
|
||||
|
||||
@@ -58,14 +58,14 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { localHmAsUtc } from "@/lib/timezone";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
timezone: TZ,
|
||||
},
|
||||
|
||||
@@ -62,7 +62,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const MED_OK = { id: "med-1", userId: "user-1" };
|
||||
|
||||
@@ -260,7 +260,7 @@ describe("PUT /api/medications/[id]/intake/[eventId] — one-shot reconcile on s
|
||||
// v1.15.18 — PUT re-runs window-band slot attribution on a takenAt change
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { localHmAsUtc } from "@/lib/timezone";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const SESSION_TZ = {
|
||||
|
||||
@@ -98,7 +98,7 @@ const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
// Pin the user timezone so slot-instant resolution is deterministic
|
||||
// regardless of the host TZ (CI runs in UTC, local in Europe/Berlin).
|
||||
|
||||
@@ -90,7 +90,7 @@ import {
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
|
||||
|
||||
@@ -52,7 +52,7 @@ 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: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const MED_OK = { id: "med-1", userId: "user-1" };
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - hard delete; the row is user-owned, non-clinical, and the
|
||||
* audit log captures enough state to reconstruct it if needed.
|
||||
*
|
||||
* Marc-decision (from the W19d brief): allow delete at any time
|
||||
* maintainer decision (from the W19d brief): allow delete at any time
|
||||
* rather than a 24-hour retraction window. Side-effect rows are not
|
||||
* a clinical record; the user owns them and a stale mis-entry should
|
||||
* never be undeletable. The audit trail preserves "what was deleted
|
||||
|
||||
@@ -60,7 +60,7 @@ 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: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const MED_OK = { id: "med-1", userId: "user-1" };
|
||||
|
||||
@@ -38,7 +38,7 @@ vi.mock("@/lib/medication-category", () => ({
|
||||
getMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
setMedicationCategory: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/timezone", () => ({
|
||||
vi.mock("@/lib/tz/local-day", () => ({
|
||||
getUserTodayBounds: vi
|
||||
.fn()
|
||||
.mockReturnValue({ start: new Date(), end: new Date() }),
|
||||
|
||||
@@ -66,7 +66,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
expectedSlotCountForDay,
|
||||
lastNonSkippedTakenAt,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
computeDisplayDue,
|
||||
OVERDUE_LOOKBACK_MS,
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* v1.16.4 — POST /api/mood-entries/restore.
|
||||
*
|
||||
* Asserts the ownership-scoped un-tombstone contract that backs the
|
||||
* delete-Undo toast: only owned, currently-tombstoned rows are touched;
|
||||
* a forged / foreign / never-deleted id is a silent no-op (no 404
|
||||
* existence leak); the mood rollup recompute collapses to the unique
|
||||
* day set; the >200-id cap returns 422.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: 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(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMood: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeMoodBucketsForEntry: 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 { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
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/mood-entries/restore", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/restore", () => {
|
||||
it("un-tombstones only owned, tombstoned rows and returns that count", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-01T20:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["e1", "e2", "foreign"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { restored: number } };
|
||||
expect(body.data.restored).toBe(2);
|
||||
|
||||
const call = vi.mocked(prisma.moodEntry.updateMany).mock.calls[0][0];
|
||||
expect(call.where).toMatchObject({
|
||||
id: { in: ["e1", "e2", "foreign"] },
|
||||
userId: "user-1",
|
||||
deletedAt: { not: null },
|
||||
});
|
||||
expect(call.data).toMatchObject({
|
||||
deletedAt: null,
|
||||
syncVersion: { increment: 1 },
|
||||
});
|
||||
|
||||
expect(invalidateUserMood).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("collapses the mood rollup recompute to the unique day set, not per-row", async () => {
|
||||
// 3 restored rows across 2 distinct UTC days → 2 recomputes.
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-01T20:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-02T09:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 3,
|
||||
} as never);
|
||||
|
||||
await POST(postReq({ ids: ["a", "b", "c"] }));
|
||||
|
||||
expect(recomputeMoodBucketsForEntry).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("is a no-op (restored: 0) when no id is owned + tombstoned", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["foreign-1", "live-1"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { restored: number } };
|
||||
expect(body.data.restored).toBe(0);
|
||||
expect(invalidateUserMood).not.toHaveBeenCalled();
|
||||
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 200-id cap with 422", async () => {
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `e${i}`);
|
||||
const res = await POST(postReq({ ids }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty id array with 422", async () => {
|
||||
const res = await POST(postReq({ ids: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("429s when the rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(postReq({ ids: ["e1"] }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* `POST /api/mood-entries/restore` — un-tombstone for the mood list's
|
||||
* delete-Undo affordance (v1.16.4).
|
||||
*
|
||||
* Body:
|
||||
* { ids: string[] } — 1..200 mood-entry ids, scoped to the caller.
|
||||
*
|
||||
* Clears `deletedAt` (+ bumps `syncVersion`) on every owned,
|
||||
* currently-tombstoned row in one `updateMany`, so the row re-surfaces
|
||||
* in normal reads and the `/api/sync/changes` delta feed carries it as
|
||||
* an upsert to paired clients. A forged / foreign / not-deleted id is a
|
||||
* silent no-op (never a 404 existence leak): the mutation `where` pins
|
||||
* `userId` so it only ever touches the caller's rows.
|
||||
*
|
||||
* Response (200):
|
||||
* { restored: <number of rows un-tombstoned> }
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
const RATE_LIMIT_MAX = 60;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const restoreSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
|
||||
|
||||
async function postRestore(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mood-entries:restore:${user.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many restore requests, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = restoreSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "mood.restore.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { ids } = parsed.data;
|
||||
|
||||
// Read the soon-to-be-restored rows first so we know which days need
|
||||
// a mood-rollup recompute. Ownership-scoped + `deletedAt != null` so a
|
||||
// forged / foreign / never-deleted id is excluded here exactly as it
|
||||
// is from the mutation below — no existence leak.
|
||||
const affected = await prisma.moodEntry.findMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
|
||||
select: { moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Un-tombstone in one statement. The `where` pins `userId` so a forged
|
||||
// id for another user is silently a no-op; `deletedAt: { not: null }`
|
||||
// keeps the count honest (a live row doesn't re-count).
|
||||
const { count } = await prisma.moodEntry.updateMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
|
||||
data: { deletedAt: null, syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await auditLog("mood.restore", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.restore" },
|
||||
meta: { count },
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
// Bust per-user mood + achievements + analytics caches.
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// Collapse the restored rows to the unique `(user, dayStart)` set
|
||||
// BEFORE recomputing — mirrors the bulk-delete path. Best-effort: a
|
||||
// populator hiccup never fails the user's restore.
|
||||
const touchedDayStarts = new Set<number>();
|
||||
for (const row of affected) {
|
||||
const d = row.moodLoggedAt;
|
||||
touchedDayStarts.add(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(touchedDayStarts).map((t) =>
|
||||
recomputeMoodBucketsForEntry(user.id, new Date(t)),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_write_failed: true,
|
||||
mood_rollup_write_error:
|
||||
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ restored: count });
|
||||
}
|
||||
@@ -70,7 +70,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_USER = {
|
||||
id: "user-mood-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
heightCm: 180,
|
||||
|
||||
@@ -58,7 +58,7 @@ import { EVENT_TYPES } from "@/lib/notifications/types";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -63,7 +63,7 @@ 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: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(body: unknown): NextRequest {
|
||||
|
||||
@@ -36,7 +36,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(url: string): NextRequest {
|
||||
|
||||
@@ -46,7 +46,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(query: string): NextRequest {
|
||||
|
||||
@@ -34,7 +34,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -56,9 +56,9 @@ describe("GET /api/user/profile", () => {
|
||||
it("returns flattened iOS-style fields", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
displayName: "Alex T.",
|
||||
email: "marc@example.com",
|
||||
email: "user@example.com",
|
||||
dateOfBirth: new Date("1985-03-12T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
@@ -76,7 +76,7 @@ describe("GET /api/user/profile", () => {
|
||||
locale: string | null;
|
||||
};
|
||||
};
|
||||
expect(body.data.username).toBe("marc");
|
||||
expect(body.data.username).toBe("testuser");
|
||||
expect(body.data.displayName).toBe("Alex T.");
|
||||
expect(body.data.heightCm).toBe(180);
|
||||
expect(body.data.locale).toBe("de");
|
||||
@@ -85,7 +85,7 @@ describe("GET /api/user/profile", () => {
|
||||
it("echoes the insurer IK number on GET", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
displayName: null,
|
||||
email: null,
|
||||
dateOfBirth: null,
|
||||
@@ -136,7 +136,7 @@ describe("PATCH /api/user/profile", () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
displayName: "Alex T.",
|
||||
email: null,
|
||||
role: "USER",
|
||||
@@ -165,7 +165,7 @@ describe("PATCH /api/user/profile", () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
displayName: null,
|
||||
email: null,
|
||||
role: "USER",
|
||||
@@ -209,7 +209,7 @@ describe("PATCH /api/user/profile", () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
displayName: null,
|
||||
email: null,
|
||||
role: "USER",
|
||||
|
||||
@@ -76,7 +76,7 @@ import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function makeRequest(
|
||||
|
||||
@@ -52,7 +52,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function makeRequest(query = ""): NextRequest {
|
||||
|
||||
@@ -50,7 +50,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
|
||||
+34
-44
@@ -13,6 +13,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -33,12 +34,16 @@ export default function LoginPage() {
|
||||
const { data: registrationEnabled } = useQuery({
|
||||
queryKey: queryKeys.authRegistrationStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/registration-status", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return true;
|
||||
const json = await res.json();
|
||||
return Boolean(json.data?.registrationEnabled ?? true);
|
||||
try {
|
||||
const data = await apiGet<{ registrationEnabled?: boolean }>(
|
||||
"/api/auth/registration-status",
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
return Boolean(data?.registrationEnabled ?? true);
|
||||
} catch {
|
||||
// Fail open, exactly as the raw `if (!res.ok) return true` did.
|
||||
return true;
|
||||
}
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
@@ -58,38 +63,30 @@ export default function LoginPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const optRes = await fetch("/api/auth/passkey/login-options", {
|
||||
method: "POST",
|
||||
});
|
||||
const optJson = await optRes.json();
|
||||
if (!optRes.ok) {
|
||||
setError(optJson.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { options, challengeId } = optJson.data;
|
||||
const { startAuthentication } = await import("@simplewebauthn/browser");
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/login-options");
|
||||
|
||||
const credential = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
const verifyRes = await fetch("/api/auth/passkey/login-verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ challengeId, credential }),
|
||||
await apiPost("/api/auth/passkey/login-verify", {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
|
||||
const verifyJson = await verifyRes.json();
|
||||
if (!verifyRes.ok) {
|
||||
setError(verifyJson.error || t("auth.loginFailed"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
router.push(getRedirectTarget());
|
||||
} catch (err) {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
// Route rejections carry the envelope error verbatim; everything
|
||||
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || t("auth.loginFailed"));
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -101,23 +98,16 @@ export default function LoginPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(json.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await apiPost("/api/auth/login", { email, password });
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
router.push(getRedirectTarget());
|
||||
} catch {
|
||||
setError(t("auth.loginFailed"));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("auth.loginFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
@@ -47,29 +48,22 @@ export default function RegisterPage() {
|
||||
// bogus, so a tampered or proxied request can never poison
|
||||
// the user's stored zone.
|
||||
const timezone = detectBrowserTimezone();
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
timezone,
|
||||
...(inviteToken ? { inviteToken } : {}),
|
||||
}),
|
||||
await apiPost("/api/auth/register", {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
timezone,
|
||||
...(inviteToken ? { inviteToken } : {}),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(json.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
router.push("/");
|
||||
} catch {
|
||||
setError(t("auth.registerFailed"));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("auth.registerFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
+23
-33
@@ -17,6 +17,7 @@ import {
|
||||
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;
|
||||
@@ -49,10 +50,7 @@ export default function BugReportPage() {
|
||||
const { data: status } = useQuery({
|
||||
queryKey: queryKeys.bugreportStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/bugreport/status");
|
||||
if (!res.ok) throw new Error("Failed to load status");
|
||||
const json = await res.json();
|
||||
return json.data as BugReportStatus;
|
||||
return apiGet<BugReportStatus>("/api/bugreport/status");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
@@ -63,36 +61,28 @@ export default function BugReportPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
},
|
||||
}),
|
||||
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"),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setResult({ type: "success", message: t("bugreport.success") });
|
||||
setSubject("");
|
||||
setDescription("");
|
||||
} else {
|
||||
setResult({
|
||||
type: "error",
|
||||
message: json.error || t("bugreport.errorCreating"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setResult({ type: "error", message: t("common.networkError") });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/medications`.
|
||||
@@ -93,10 +94,7 @@ export default function InsightsMedikamentePage() {
|
||||
const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as ComprehensiveMedicationData;
|
||||
return apiGet<ComprehensiveMedicationData>("/api/insights/comprehensive");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
@@ -110,13 +108,7 @@ export default function InsightsMedikamentePage() {
|
||||
MED_STATUS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/insights/medication-compliance-status?locale=${locale}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as MedicationComplianceStatusData;
|
||||
return apiGet<MedicationComplianceStatusData>(`/api/insights/medication-compliance-status?locale=${locale}`, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
@@ -313,10 +305,15 @@ function MedicationComplianceCalendar({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.medicationComplianceChart(medicationId),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medicationId}/compliance`);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as MedicationComplianceDailyResponse;
|
||||
try {
|
||||
return await apiGet<MedicationComplianceDailyResponse>(
|
||||
`/api/medications/${medicationId}/compliance`,
|
||||
);
|
||||
} catch {
|
||||
// The calendar simply doesn't render — same as the raw
|
||||
// `if (!res.ok) return null` did.
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!medicationId,
|
||||
staleTime: 60 * 1000,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { MetricTargetSummary } from "@/components/insights/metric-target-summary
|
||||
import { MoodInsightsSections } from "@/components/insights/mood/mood-insights-sections";
|
||||
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/mood`.
|
||||
@@ -49,10 +50,7 @@ export default function InsightsStimmungPage() {
|
||||
const { data: comprehensive } = useQuery({
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as ComprehensiveMoodData;
|
||||
return apiGet<ComprehensiveMoodData>("/api/insights/comprehensive");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import {
|
||||
@@ -202,7 +203,7 @@ const CycleRingTile = dynamic(
|
||||
* cards moved onto the metric pages they belong to (Weight owns
|
||||
* weight × weekday, Pulse owns mood × pulse, …), so the overview
|
||||
* no longer renders a duplicate correlation row.
|
||||
* - The CoachDrawer is mounted in the mother-page body only (Marc
|
||||
* - The CoachDrawer is mounted in the mother-page body only (the maintainer
|
||||
* directive). Navigating to a sub-page unmounts the drawer.
|
||||
*
|
||||
* The per-section status cards (BP/Weight/Pulse/etc.) and their
|
||||
@@ -250,10 +251,11 @@ export default function InsightsPage() {
|
||||
const { data, isLoading, isFetched, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
if (!res.ok) throw new Error(t("insights.loadError"));
|
||||
const json = await res.json();
|
||||
return json.data as ComprehensiveData;
|
||||
try {
|
||||
return await apiGet<ComprehensiveData>("/api/insights/comprehensive");
|
||||
} catch {
|
||||
throw new Error(t("insights.loadError"));
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
+9
-1
@@ -8,6 +8,7 @@ import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { allMessages } from "@/lib/i18n/shared-resolve";
|
||||
|
||||
async function resolveInitialLocale(): Promise<Locale> {
|
||||
// Both cookies() and headers() can throw (DynamicServerError, etc.) —
|
||||
@@ -106,6 +107,13 @@ export default async function RootLayout({
|
||||
: undefined;
|
||||
|
||||
const initialLocale = await resolveInitialLocale();
|
||||
// RSC handoff for the i18n bundle split: only EN ships statically in
|
||||
// the client chunk, so a non-EN first paint needs its bundle inlined
|
||||
// into the payload here — that is what keeps the split free of the
|
||||
// EN→DE hydration flash. EN passes nothing (the client already holds
|
||||
// the static fallback floor).
|
||||
const initialMessages =
|
||||
initialLocale === "en" ? undefined : allMessages[initialLocale];
|
||||
|
||||
return (
|
||||
<html lang={initialLocale} suppressHydrationWarning>
|
||||
@@ -117,7 +125,7 @@ export default async function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<Providers initialLocale={initialLocale}>
|
||||
<Providers initialLocale={initialLocale} initialMessages={initialMessages}>
|
||||
<MonitoringBootstrap />
|
||||
<WebVitalsReporter />
|
||||
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
MedicationDetailTabs,
|
||||
type MedicationDetailSnapshot,
|
||||
} from "@/components/medications/detail/MedicationDetailTabs";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
export default function MedicationDetailPage({
|
||||
params,
|
||||
@@ -52,9 +53,7 @@ export default function MedicationDetailPage({
|
||||
} = useQuery<MedicationDetailSnapshot>({
|
||||
queryKey: queryKeys.medicationDetail(id),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${id}`);
|
||||
if (!res.ok) throw new Error("medication_detail_failed");
|
||||
return (await res.json()).data as MedicationDetailSnapshot;
|
||||
return apiGet<MedicationDetailSnapshot>(`/api/medications/${id}`);
|
||||
},
|
||||
// v1.15.20 — deliberately NOT gated on `isAuthenticated`: the session
|
||||
// cookie rides the fetch either way, so waiting for `/api/auth/me`
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CheckCircle2, Loader2, Pill, Plus } from "lucide-react";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -149,10 +150,7 @@ export default function MedicationsPage() {
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.medications(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/medications");
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
const json = await res.json();
|
||||
return json.data as Medication[];
|
||||
return apiGet<Medication[]>("/api/medications");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type EventType,
|
||||
} from "@/lib/notifications/types";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
interface ChannelInfo {
|
||||
id: string;
|
||||
@@ -91,10 +92,7 @@ export default function NotificationsPage() {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.notificationsPreferences(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/notifications/preferences");
|
||||
if (!res.ok) throw new Error("Failed to load preferences");
|
||||
const json = await res.json();
|
||||
return json.data as PreferencesData;
|
||||
return apiGet<PreferencesData>("/api/notifications/preferences");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
@@ -105,13 +103,7 @@ export default function NotificationsPage() {
|
||||
eventType: string;
|
||||
enabled: boolean;
|
||||
}) => {
|
||||
const res = await fetch("/api/notifications/preferences", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update preference");
|
||||
return res.json();
|
||||
return apiPut("/api/notifications/preferences", params);
|
||||
},
|
||||
onMutate: async ({ channelId, eventType, enabled }) => {
|
||||
await queryClient.cancelQueries({
|
||||
|
||||
@@ -89,7 +89,7 @@ export default async function OnboardingStepPage({ params }: PageProps) {
|
||||
// Welcome (step 0)
|
||||
// * Fresh user → value-prop carousel.
|
||||
// * Returning user (onboarding already completed) → welcome-back
|
||||
// banner with "Open dashboard" CTA. Marc note 2026-05-14: the
|
||||
// banner with "Open dashboard" CTA. The maintainer note 2026-05-14: the
|
||||
// "restart onboarding" affordance lives in Settings → Account in
|
||||
// v1.4.26; for now the banner is informational only.
|
||||
if (requested === 0) {
|
||||
|
||||
+7
-12
@@ -96,6 +96,7 @@ import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.7.0 — first-paint gate for the dashboard tile strip.
|
||||
@@ -148,7 +149,7 @@ export default function DashboardPage() {
|
||||
// (thick slice) blocked every per-type tile until the heavy fan-out
|
||||
// resolved. Mood and medication tiles paint from their own dedicated
|
||||
// endpoints and arrived first; the per-type measurement tiles then
|
||||
// arrived as one burst once the full slice landed — Marc reported
|
||||
// arrived as one burst once the full slice landed — the maintainer reported
|
||||
// this "blocked-then-burst" pattern as "etwas nervig".
|
||||
//
|
||||
// Post-fix: two parallel queries. The slim slice (`?slice=summaries`)
|
||||
@@ -205,7 +206,7 @@ export default function DashboardPage() {
|
||||
// coverage. Pre-fix the inline `slim?.summaries ?? thick?.summaries`
|
||||
// short-circuited on a truthy-but-empty `{}` from the slim slice
|
||||
// and blanked the tile strip even when thick carried the full
|
||||
// payload — the regression Marc's v1.4.39.3 e2e CI flagged across
|
||||
// payload — the regression the maintainer's v1.4.39.3 e2e CI flagged across
|
||||
// eight dashboard / chart specs. The helper falls back to thick
|
||||
// when slim resolves with no content and otherwise keeps the
|
||||
// v1.4.39.2 slim-wins-first progressive-paint contract.
|
||||
@@ -237,10 +238,7 @@ export default function DashboardPage() {
|
||||
const { data: layoutDataLegacy } = useQuery({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as DashboardLayout;
|
||||
return apiGet<DashboardLayout>("/api/dashboard/widgets");
|
||||
},
|
||||
enabled: !snapshotEnabled && isAuthenticated,
|
||||
...DASHBOARD_QUERY_OPTS,
|
||||
@@ -252,13 +250,10 @@ export default function DashboardPage() {
|
||||
const { data: moodDataLegacy } = useQuery({
|
||||
queryKey: queryKeys.moodAnalytics(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/mood/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as {
|
||||
return apiGet<{
|
||||
entries: Array<{ date: string; score: number; samples: number }>;
|
||||
summary: DataSummary;
|
||||
};
|
||||
}>("/api/mood/analytics");
|
||||
},
|
||||
enabled: !snapshotEnabled && isAuthenticated,
|
||||
...DASHBOARD_QUERY_OPTS,
|
||||
@@ -1362,7 +1357,7 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
{/* v1.4: dashboard tiles are *always* a single row.
|
||||
* maintainer-explicit (per feedback_dashboard_one_row.md): a 2-row
|
||||
* Maintainer-explicit (per feedback_dashboard_one_row.md): a 2-row
|
||||
* tile strip breaks the visual hierarchy and reads like an
|
||||
* Excel grid. Total width caps at the parent container —
|
||||
* exactly the chart-width below. When the active tile count
|
||||
|
||||
@@ -245,10 +245,11 @@ describe("<PrivacyPage>", () => {
|
||||
// email address attaches the actual contact.
|
||||
expect(html).toContain("mailto:mbombeck@gmail.com");
|
||||
expect(html).toContain("github.com/MBombeck/HealthLog/issues");
|
||||
// Marc-Voice rule: no personal name in user-facing artefacts. The
|
||||
// policy speaks in terms of "the operator" / "the controller"
|
||||
// not Marc's full name. Only the bare email address attaches the
|
||||
// contact route.
|
||||
// Project-voice rule: no personal name in user-facing artefacts.
|
||||
// The policy speaks in terms of "the operator" / "the controller",
|
||||
// never a person's full name. Only the bare email address attaches
|
||||
// the contact route. The regexes pin the operator's given-name /
|
||||
// surname fragments so a regression cannot reintroduce them.
|
||||
expect(html).not.toMatch(/Marc[- ]?Andr/i);
|
||||
expect(html).not.toMatch(/\bBombeck\s+[A-Z]/);
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("<AiQualitySection>", () => {
|
||||
mockQueryState.isError = false;
|
||||
const html = render(<AiQualitySection />);
|
||||
// v1.4.33 IW7 — admin section renamed from "AI Quality" to
|
||||
// "Insights Quality" per the Marc-Voice rule.
|
||||
// "Insights Quality" per the project-voice rule.
|
||||
expect(html).toContain("Insights Quality");
|
||||
expect(html).toContain("No feedback yet");
|
||||
});
|
||||
|
||||
@@ -35,8 +35,8 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
username: "testuser",
|
||||
email: "user@example.com",
|
||||
role: "ADMIN",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
|
||||
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user