diff --git a/.env.production.example b/.env.production.example index 2a3edb30a..55bcd326f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -234,3 +234,16 @@ API_TOKEN_HMAC_KEY="" # Set to "1" to disable all third-party IP egress (GDPR Art. 44). With egress # off the resolver uses only the offline GeoLite2 MMDB tier, if present. # IP_GEO_LOOKUP_DISABLED="1" + +# ----------------------------------------------------------------------------- +# Environmental-context module (Open-Meteo) -- optional +# ----------------------------------------------------------------------------- +# The opt-in Environment module fetches daily weather / daylight for the user's +# coarse home (or a travel override) from Open-Meteo. The hosted endpoints are +# keyless and free for non-commercial use; data is CC BY 4.0. Both URLs are +# optional -- unset uses the hosted defaults below. A privacy-maximalist or +# commercial self-hoster can run their own Open-Meteo instance and point these +# at it (a private/LAN host is allowed -- the fetch is server-side and does NOT +# force the public-host guard, so an internal instance is not blocked). +# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com" +# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eed99e898..2b2d945eb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9746,6 +9746,8 @@ components: type: boolean doctorReport: type: boolean + environment: + type: boolean mcp: type: boolean additionalProperties: false @@ -16786,6 +16788,9 @@ components: doctorReport: type: boolean description: Whether the "doctorReport" module is enabled for this account. + environment: + type: boolean + description: Whether the "environment" module is enabled for this account. mcp: type: boolean description: Whether the "mcp" module is enabled for this account. @@ -16803,6 +16808,7 @@ components: - insights - medications - doctorReport + - environment - mcp additionalProperties: false description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means diff --git a/messages/de.json b/messages/de.json index 6e0d70d1a..9da0e1620 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4279,6 +4279,43 @@ "title": "Anamnese", "subtitle": "Verwalte deine Allergien und deine Familienanamnese.", "description": "Strukturierte Datensätze zu Allergien und Familienanamnese." + }, + "environment": { + "title": "Umwelt", + "subtitle": "Wetter, Tageslicht und Standort-Kontext.", + "disabledHint": "Aktiviere das Umwelt-Modul, um einen Heimatort festzulegen und tägliches Wetter und Tageslicht abzurufen.", + "searchPlaceholder": "Stadt suchen", + "search": "Suchen", + "searchError": "Ort konnte nicht gesucht werden.", + "use": "Verwenden", + "select": "Auswählen", + "saveError": "Änderung konnte nicht gespeichert werden.", + "homeSaved": "Heimatort gespeichert.", + "travelAdded": "Reise-Ausnahme hinzugefügt.", + "travelRemoved": "Reise-Ausnahme entfernt.", + "backfillQueued": "Nacherfassung eingeplant.", + "backfillError": "Nacherfassung konnte nicht gestartet werden.", + "startDate": "Startdatum", + "endDate": "Enddatum", + "storedDays": "{count} Tage Wetter gespeichert.", + "home": { + "title": "Heimatort", + "description": "Ein grober Ort auf Stadtebene – niemals ein genauer Standortverlauf. Wetter und Tageslicht werden für diesen Ort an allen Tagen ohne Reise-Ausnahme abgerufen.", + "current": "Aktuell: {label}", + "none": "Noch kein Heimatort festgelegt." + }, + "travel": { + "title": "Reise-Ausnahmen", + "description": "Für einen Zeitraum, den du woanders verbracht hast, den Ort festlegen, damit das Wetter zu deinem tatsächlichen Aufenthalt passt.", + "none": "Keine Reise-Ausnahmen.", + "add": "{label} hinzufügen", + "remove": "Reise-Ausnahme entfernen" + }, + "backfill": { + "title": "Vergangene Tage nacherfassen", + "description": "Wetter für einen vergangenen Zeitraum abrufen und speichern, damit ältere Tage in die Korrelationen einfließen.", + "run": "Nacherfassen" + } } }, "profile": "Profil", @@ -7220,6 +7257,10 @@ "mcp": { "label": "Externer Assistentenzugriff (MCP)", "description": "Einem MCP-fähigen Assistenten Lesezugriff auf deine Gesundheitsdaten über ein begrenztes Token erlauben. Standardmäßig aus." + }, + "environment": { + "label": "Umwelt", + "description": "Tägliches Wetter und Tageslicht für deinen Ort einbeziehen, um Zusammenhänge mit Stimmung, Schlaf und Vitalwerten zu erkennen. Standardmäßig aus." } }, "illness": { @@ -7671,5 +7712,16 @@ "saveError": "Der Eintrag konnte nicht gespeichert werden." } } + }, + "environment": { + "fields": { + "tempMean": "Tagestemperatur", + "tempMin": "Nachttiefstwert", + "sunshine": "Sonnenschein", + "daylight": "Tageslicht", + "precip": "Niederschlag", + "pressureMean": "Luftdruck", + "pressureDelta": "Druckschwankung" + } } } diff --git a/messages/en.json b/messages/en.json index 2e6526ac0..7e0464c13 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4279,6 +4279,43 @@ "title": "Medical history", "subtitle": "Manage your allergies and family history.", "description": "Structured allergies and family history records." + }, + "environment": { + "title": "Environment", + "subtitle": "Weather, daylight, and location context.", + "disabledHint": "Turn on the Environment module to set a home location and pull in daily weather and daylight.", + "searchPlaceholder": "Search for a city", + "search": "Search", + "searchError": "Could not search for that place.", + "use": "Use", + "select": "Select", + "saveError": "Could not save your change.", + "homeSaved": "Home location saved.", + "travelAdded": "Travel override added.", + "travelRemoved": "Travel override removed.", + "backfillQueued": "Backfill queued.", + "backfillError": "Could not start the backfill.", + "startDate": "Start date", + "endDate": "End date", + "storedDays": "{count} days of weather stored.", + "home": { + "title": "Home location", + "description": "A coarse, city-level location — never a precise track. Weather and daylight are fetched for this place on days no travel override applies.", + "current": "Current: {label}", + "none": "No home location set yet." + }, + "travel": { + "title": "Travel overrides", + "description": "For a date range you spent elsewhere, set the location so the weather matches where you actually were.", + "none": "No travel overrides.", + "add": "Add {label}", + "remove": "Remove travel override" + }, + "backfill": { + "title": "Backfill past days", + "description": "Fetch and store weather for a past date range so older days can join the correlations.", + "run": "Backfill" + } } }, "profile": "Profile", @@ -7220,6 +7257,10 @@ "mcp": { "label": "External assistant access (MCP)", "description": "Let an MCP-capable assistant read your health data over a scoped token. Off by default." + }, + "environment": { + "label": "Environment", + "description": "Pull in daily weather and daylight for your location to correlate with mood, sleep, and vitals. Off by default." } }, "illness": { @@ -7671,5 +7712,16 @@ "saveError": "Couldn't save the entry." } } + }, + "environment": { + "fields": { + "tempMean": "Daily temperature", + "tempMin": "Overnight low", + "sunshine": "Sunshine", + "daylight": "Daylight", + "precip": "Precipitation", + "pressureMean": "Barometric pressure", + "pressureDelta": "Pressure swing" + } } } diff --git a/messages/es.json b/messages/es.json index 7bc071632..723471a33 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4279,6 +4279,43 @@ "title": "Historial médico", "subtitle": "Gestiona tus alergias y tus antecedentes familiares.", "description": "Registros estructurados de alergias y antecedentes familiares." + }, + "environment": { + "title": "Entorno", + "subtitle": "Contexto de tiempo, luz diurna y ubicación.", + "disabledHint": "Activa el módulo Entorno para definir una ubicación principal e incorporar el tiempo y las horas de luz diarias.", + "searchPlaceholder": "Buscar una ciudad", + "search": "Buscar", + "searchError": "No se pudo buscar ese lugar.", + "use": "Usar", + "select": "Seleccionar", + "saveError": "No se pudo guardar el cambio.", + "homeSaved": "Ubicación principal guardada.", + "travelAdded": "Excepción de viaje añadida.", + "travelRemoved": "Excepción de viaje eliminada.", + "backfillQueued": "Relleno programado.", + "backfillError": "No se pudo iniciar el relleno.", + "startDate": "Fecha de inicio", + "endDate": "Fecha de fin", + "storedDays": "{count} días de tiempo almacenados.", + "home": { + "title": "Ubicación principal", + "description": "Una ubicación aproximada a nivel de ciudad, nunca un rastro preciso. El tiempo y las horas de luz se obtienen para este lugar los días sin excepción de viaje.", + "current": "Actual: {label}", + "none": "Aún no hay ubicación principal." + }, + "travel": { + "title": "Excepciones de viaje", + "description": "Para un rango de fechas que pasaste en otro sitio, define la ubicación para que el tiempo coincida con dónde estuviste realmente.", + "none": "Sin excepciones de viaje.", + "add": "Añadir {label}", + "remove": "Eliminar excepción de viaje" + }, + "backfill": { + "title": "Rellenar días pasados", + "description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días anteriores entren en las correlaciones.", + "run": "Rellenar" + } } }, "profile": "Perfil", @@ -7220,6 +7257,10 @@ "mcp": { "label": "Acceso de asistente externo (MCP)", "description": "Permite que un asistente compatible con MCP lea tus datos de salud mediante un token con alcance limitado. Desactivado de forma predeterminada." + }, + "environment": { + "label": "Entorno", + "description": "Incorpora el tiempo y las horas de luz diarias de tu ubicación para correlacionarlos con el ánimo, el sueño y las constantes. Desactivado por defecto." } }, "illness": { @@ -7671,5 +7712,16 @@ "saveError": "No se ha podido guardar la entrada." } } + }, + "environment": { + "fields": { + "tempMean": "Temperatura diaria", + "tempMin": "Mínima nocturna", + "sunshine": "Sol", + "daylight": "Luz diurna", + "precip": "Precipitación", + "pressureMean": "Presión atmosférica", + "pressureDelta": "Variación de presión" + } } } diff --git a/messages/fr.json b/messages/fr.json index c5258abf5..3080c446a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4279,6 +4279,43 @@ "title": "Antécédents médicaux", "subtitle": "Gérez vos allergies et vos antécédents familiaux.", "description": "Dossiers structurés des allergies et des antécédents familiaux." + }, + "environment": { + "title": "Environnement", + "subtitle": "Contexte météo, lumière du jour et lieu.", + "disabledHint": "Activez le module Environnement pour définir un lieu de référence et intégrer la météo et la durée du jour quotidiennes.", + "searchPlaceholder": "Rechercher une ville", + "search": "Rechercher", + "searchError": "Impossible de rechercher ce lieu.", + "use": "Utiliser", + "select": "Sélectionner", + "saveError": "Impossible d'enregistrer la modification.", + "homeSaved": "Lieu de référence enregistré.", + "travelAdded": "Exception de voyage ajoutée.", + "travelRemoved": "Exception de voyage supprimée.", + "backfillQueued": "Récupération planifiée.", + "backfillError": "Impossible de lancer la récupération.", + "startDate": "Date de début", + "endDate": "Date de fin", + "storedDays": "{count} jours de météo enregistrés.", + "home": { + "title": "Lieu de référence", + "description": "Un lieu approximatif à l'échelle de la ville, jamais un suivi précis. La météo et la durée du jour sont récupérées pour ce lieu les jours sans exception de voyage.", + "current": "Actuel : {label}", + "none": "Aucun lieu de référence défini." + }, + "travel": { + "title": "Exceptions de voyage", + "description": "Pour une période passée ailleurs, définissez le lieu afin que la météo corresponde à l'endroit où vous étiez réellement.", + "none": "Aucune exception de voyage.", + "add": "Ajouter {label}", + "remove": "Supprimer l'exception de voyage" + }, + "backfill": { + "title": "Récupérer les jours passés", + "description": "Récupérez et enregistrez la météo d'une période passée pour que les jours plus anciens entrent dans les corrélations.", + "run": "Récupérer" + } } }, "profile": "Profil", @@ -7220,6 +7257,10 @@ "mcp": { "label": "Accès assistant externe (MCP)", "description": "Autorise un assistant compatible MCP à lire vos données de santé via un jeton à portée limitée. Désactivé par défaut." + }, + "environment": { + "label": "Environnement", + "description": "Intégrez la météo et la durée du jour quotidiennes de votre lieu pour les corréler à l'humeur, au sommeil et aux constantes. Désactivé par défaut." } }, "illness": { @@ -7671,5 +7712,16 @@ "saveError": "Impossible d'enregistrer l'entrée." } } + }, + "environment": { + "fields": { + "tempMean": "Température quotidienne", + "tempMin": "Minimale nocturne", + "sunshine": "Ensoleillement", + "daylight": "Durée du jour", + "precip": "Précipitations", + "pressureMean": "Pression atmosphérique", + "pressureDelta": "Variation de pression" + } } } diff --git a/messages/it.json b/messages/it.json index 17aed052e..a9827bb96 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4279,6 +4279,43 @@ "title": "Anamnesi", "subtitle": "Gestisci le tue allergie e la tua anamnesi familiare.", "description": "Record strutturati di allergie e anamnesi familiare." + }, + "environment": { + "title": "Ambiente", + "subtitle": "Contesto meteo, luce del giorno e posizione.", + "disabledHint": "Attiva il modulo Ambiente per impostare una posizione principale e integrare il meteo e le ore di luce giornaliere.", + "searchPlaceholder": "Cerca una città", + "search": "Cerca", + "searchError": "Impossibile cercare quel luogo.", + "use": "Usa", + "select": "Seleziona", + "saveError": "Impossibile salvare la modifica.", + "homeSaved": "Posizione principale salvata.", + "travelAdded": "Eccezione di viaggio aggiunta.", + "travelRemoved": "Eccezione di viaggio rimossa.", + "backfillQueued": "Recupero pianificato.", + "backfillError": "Impossibile avviare il recupero.", + "startDate": "Data di inizio", + "endDate": "Data di fine", + "storedDays": "{count} giorni di meteo salvati.", + "home": { + "title": "Posizione principale", + "description": "Una posizione approssimativa a livello di città, mai un tracciamento preciso. Il meteo e le ore di luce vengono recuperati per questo luogo nei giorni senza eccezioni di viaggio.", + "current": "Attuale: {label}", + "none": "Nessuna posizione principale impostata." + }, + "travel": { + "title": "Eccezioni di viaggio", + "description": "Per un intervallo di date trascorso altrove, imposta la posizione in modo che il meteo corrisponda a dove eri davvero.", + "none": "Nessuna eccezione di viaggio.", + "add": "Aggiungi {label}", + "remove": "Rimuovi eccezione di viaggio" + }, + "backfill": { + "title": "Recupera i giorni passati", + "description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni.", + "run": "Recupera" + } } }, "profile": "Profilo", @@ -7220,6 +7257,10 @@ "mcp": { "label": "Accesso assistente esterno (MCP)", "description": "Consente a un assistente compatibile con MCP di leggere i tuoi dati sanitari tramite un token con ambito limitato. Disattivato per impostazione predefinita." + }, + "environment": { + "label": "Ambiente", + "description": "Integra il meteo e le ore di luce giornaliere della tua posizione per correlarli con umore, sonno e parametri vitali. Disattivato di default." } }, "illness": { @@ -7671,5 +7712,16 @@ "saveError": "Impossibile salvare la voce." } } + }, + "environment": { + "fields": { + "tempMean": "Temperatura giornaliera", + "tempMin": "Minima notturna", + "sunshine": "Sole", + "daylight": "Ore di luce", + "precip": "Precipitazioni", + "pressureMean": "Pressione atmosferica", + "pressureDelta": "Variazione di pressione" + } } } diff --git a/messages/pl.json b/messages/pl.json index 84bd67359..2ea5d0193 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4279,6 +4279,43 @@ "title": "Wywiad medyczny", "subtitle": "Zarządzaj swoimi alergiami i wywiadem rodzinnym.", "description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego." + }, + "environment": { + "title": "Środowisko", + "subtitle": "Kontekst pogody, światła dziennego i lokalizacji.", + "disabledHint": "Włącz moduł Środowisko, aby ustawić lokalizację domową i pobierać dzienną pogodę oraz długość dnia.", + "searchPlaceholder": "Szukaj miasta", + "search": "Szukaj", + "searchError": "Nie udało się wyszukać tego miejsca.", + "use": "Użyj", + "select": "Wybierz", + "saveError": "Nie udało się zapisać zmiany.", + "homeSaved": "Zapisano lokalizację domową.", + "travelAdded": "Dodano wyjątek podróży.", + "travelRemoved": "Usunięto wyjątek podróży.", + "backfillQueued": "Zaplanowano uzupełnianie.", + "backfillError": "Nie udało się rozpocząć uzupełniania.", + "startDate": "Data początkowa", + "endDate": "Data końcowa", + "storedDays": "Zapisano dni pogody: {count}.", + "home": { + "title": "Lokalizacja domowa", + "description": "Przybliżona lokalizacja na poziomie miasta — nigdy dokładny ślad. Pogoda i długość dnia są pobierane dla tego miejsca w dni bez wyjątku podróży.", + "current": "Obecnie: {label}", + "none": "Nie ustawiono jeszcze lokalizacji domowej." + }, + "travel": { + "title": "Wyjątki podróży", + "description": "Dla zakresu dat spędzonego gdzie indziej ustaw lokalizację, aby pogoda odpowiadała temu, gdzie naprawdę byłeś.", + "none": "Brak wyjątków podróży.", + "add": "Dodaj {label}", + "remove": "Usuń wyjątek podróży" + }, + "backfill": { + "title": "Uzupełnij minione dni", + "description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji.", + "run": "Uzupełnij" + } } }, "profile": "Profil", @@ -7220,6 +7257,10 @@ "mcp": { "label": "Dostęp asystenta zewnętrznego (MCP)", "description": "Pozwól asystentowi obsługującemu MCP czytać Twoje dane zdrowotne za pomocą tokena o ograniczonym zakresie. Domyślnie wyłączone." + }, + "environment": { + "label": "Środowisko", + "description": "Pobieraj dzienną pogodę i długość dnia dla swojej lokalizacji, aby powiązać je z nastrojem, snem i parametrami życiowymi. Domyślnie wyłączone." } }, "illness": { @@ -7671,5 +7712,16 @@ "saveError": "Nie udało się zapisać wpisu." } } + }, + "environment": { + "fields": { + "tempMean": "Temperatura dzienna", + "tempMin": "Minimum nocne", + "sunshine": "Nasłonecznienie", + "daylight": "Światło dzienne", + "precip": "Opady", + "pressureMean": "Ciśnienie atmosferyczne", + "pressureDelta": "Wahanie ciśnienia" + } } } diff --git a/prisma/migrations/0217_v125_environmental_context/migration.sql b/prisma/migrations/0217_v125_environmental_context/migration.sql new file mode 100644 index 000000000..4cc4e661f --- /dev/null +++ b/prisma/migrations/0217_v125_environmental_context/migration.sql @@ -0,0 +1,101 @@ +-- v1.25 (W-ENV) — environmental-context module (opt-in, server-side v1). +-- +-- Adds the per-day weather/daylight observation store and the manual travel +-- overrides, plus the coarse home-location columns on `users`. The module is +-- opt-in (default OFF via the module registry); with it off no row is written +-- and no outbound weather fetch runs. +-- +-- Location granularity is coarse (city level / rounded lat-lon) by design — the +-- weather model only needs ~10–25 km and HealthLog never stores a GPS track. +-- +-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` / +-- `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "environment_travel_locations"; +-- DROP TABLE IF EXISTS "environment_contexts"; +-- DROP TYPE IF EXISTS "environment_location_source"; +-- ALTER TABLE "users" +-- DROP COLUMN IF EXISTS "home_lat", +-- DROP COLUMN IF EXISTS "home_lon", +-- DROP COLUMN IF EXISTS "home_label", +-- DROP COLUMN IF EXISTS "home_timezone"; +-- A roll-back drops the environment store wholesale — no other domain depends +-- on it. + +-- AlterTable: coarse home location on the user (all nullable). +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_lat" DOUBLE PRECISION; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_lon" DOUBLE PRECISION; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_label" TEXT; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_timezone" TEXT; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "environment_location_source" AS ENUM ('HOME', 'TRAVEL'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "environment_contexts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "date" TEXT NOT NULL, + "lat" DOUBLE PRECISION NOT NULL, + "lon" DOUBLE PRECISION NOT NULL, + "location_label" TEXT NOT NULL, + "source" "environment_location_source" NOT NULL, + "temp_min" DOUBLE PRECISION, + "temp_max" DOUBLE PRECISION, + "temp_mean" DOUBLE PRECISION, + "apparent_mean" DOUBLE PRECISION, + "sunshine_sec" INTEGER, + "daylight_sec" INTEGER, + "precip_sum" DOUBLE PRECISION, + "pressure_mean" DOUBLE PRECISION, + "pressure_delta" DOUBLE PRECISION, + "humidity_mean" DOUBLE PRECISION, + "cloud_mean" DOUBLE PRECISION, + "weather_code" INTEGER, + "fetched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "environment_contexts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "environment_contexts_user_id_date_key" ON "environment_contexts"("user_id", "date"); +CREATE INDEX IF NOT EXISTS "environment_contexts_user_id_date_idx" ON "environment_contexts"("user_id", "date"); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "environment_travel_locations" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "start_date" TEXT NOT NULL, + "end_date" TEXT NOT NULL, + "lat" DOUBLE PRECISION NOT NULL, + "lon" DOUBLE PRECISION NOT NULL, + "label" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "environment_travel_locations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "environment_travel_locations_user_id_start_date_end_date_idx" ON "environment_travel_locations"("user_id", "start_date", "end_date"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "environment_contexts" ADD CONSTRAINT "environment_contexts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "environment_travel_locations" ADD CONSTRAINT "environment_travel_locations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e862c91f0..6687ea8f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,19 @@ model User { gender String? @map("gender") // "MALE" | "FEMALE" | "OTHER" | null = keine Angabe timezone String @default("Europe/Berlin") + // v1.25 (W-ENV) — opt-in environmental-context module home location. A coarse, + // user-set city (rounded lat/lon, ~city granularity — never a GPS track) the + // nightly weather fetch resolves against when no travel override covers the + // day. City-level granularity is the privacy floor: the weather model only + // needs ~10–25 km. All nullable — an account without the module enabled (or + // without a home set) simply has no environment rows. `homeTimezone` anchors + // the day-key an observation is stored under (it can differ from the display + // `timezone` above for an account that tracks weather for another locale). + homeLat Float? @map("home_lat") + homeLon Float? @map("home_lon") + homeLabel String? @map("home_label") + homeTimezone String? @map("home_timezone") + // Codex OAuth (ChatGPT subscription) codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") @@ -727,6 +740,12 @@ model User { // sensitive free-text fields are encrypted at rest. allergies Allergy[] familyHistory FamilyHistoryEntry[] + // v1.25 (W-ENV) — opt-in environmental-context module. `EnvironmentContext` + // is the per-day weather/daylight observation keyed to the resolved location; + // `EnvironmentTravelLocation` holds the manual date-range travel overrides + // that win over the home location for the days they cover. + environmentContexts EnvironmentContext[] + environmentTravelLocations EnvironmentTravelLocation[] // v1.19.0 — full Withings ECG waveform recordings (encrypted at rest). // The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION // Measurement EVENT row (v1.18.11); this holds the raw signal samples. @@ -782,6 +801,99 @@ model User { @@map("users") } +// ─── v1.25 (W-ENV) Environmental-context module ────────────────────────────── + +/// How the location for an environment observation was resolved. HOME = the +/// user's configured home location; TRAVEL = a manual date-range override. +/// (iOS coarse device location is a v2 follow-up and not represented here.) +enum EnvironmentLocationSource { + HOME + TRAVEL +} + +/// A per-day environmental observation (daily weather / daylight) for the +/// location resolved for that day. Day-keyed exactly like the rollup tier: +/// `date` is a YYYY-MM-DD string anchored to the location's IANA timezone, so a +/// late-evening reading does not mis-bucket under UTC. One row per (user, day); +/// a re-fetch upserts (the archive feed settles over a few days). All weather +/// fields are nullable so a partial feed still stores what it has. The location +/// stored here is coarse (city granularity) — never a GPS track. +model EnvironmentContext { + id String @id @default(cuid()) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// YYYY-MM-DD anchored to the resolved location's timezone. + date String + + /// Coarse resolved location for the day (rounded lat/lon + label + the source + /// that won precedence). + lat Float + lon Float + locationLabel String @map("location_label") + source EnvironmentLocationSource + + /// Daily temperature (°C). Min doubles as the overnight-low proxy. + tempMin Float? @map("temp_min") + tempMax Float? @map("temp_max") + tempMean Float? @map("temp_mean") + /// Daily apparent ("feels-like") mean (°C). + apparentMean Float? @map("apparent_mean") + + /// Sunshine + daylight, stored in seconds (the feed's native unit). + sunshineSec Int? @map("sunshine_sec") + daylightSec Int? @map("daylight_sec") + + /// Precipitation total (mm) over the local day. + precipSum Float? @map("precip_sum") + + /// Pressure aggregated from the hourly feed: daily mean (hPa) + the intraday + /// delta (max−min, hPa) — the derived feature for the headache/symptom overlay. + pressureMean Float? @map("pressure_mean") + pressureDelta Float? @map("pressure_delta") + + /// Relative humidity mean (%) and cloud-cover mean (%) from the hourly feed. + humidityMean Float? @map("humidity_mean") + cloudMean Float? @map("cloud_mean") + + /// WMO weather code for the day (descriptive condition lookup). + weatherCode Int? @map("weather_code") + + /// When the row was last fetched/refreshed from the upstream feed. + fetchedAt DateTime @default(now()) @map("fetched_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([userId, date]) + @@index([userId, date]) + @@map("environment_contexts") +} + +/// A manual travel override: for any day in `[startDate, endDate]` the weather +/// fetch resolves against this location instead of the home location. Declared, +/// not inferred — a stated trip dominates the home fallback so a winter week in +/// a sunny locale is not mis-attributed to the home city. Coarse location only. +model EnvironmentTravelLocation { + id String @id @default(cuid()) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// Inclusive YYYY-MM-DD range (anchored to the user's home timezone for the + /// day-key the override matches against). + startDate String @map("start_date") + endDate String @map("end_date") + + lat Float + lon Float + label String + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([userId, startDate, endDate]) + @@map("environment_travel_locations") +} + model Passkey { id String @id @default(cuid()) userId String @map("user_id") @@ -1158,37 +1270,37 @@ enum AssessmentInstrument { /// administration (linked by `externalId = assessment:`) so the score rides /// the existing chart / rollup infra — the trend, never the item content. model MentalHealthAssessment { - id String @id @default(cuid()) - userId String @map("user_id") - instrument AssessmentInstrument + id String @id @default(cuid()) + userId String @map("user_id") + instrument AssessmentInstrument /// The validated translation actually presented (officially-validated wording /// per locale, English fallback otherwise — recorded for the audit trail). - locale String + locale String /// Instrument variant identifier (e.g. "standard"); future-proofs revisions. - version String @default("standard") + version String @default("standard") /// Encrypted JSON blob: { items: number[], functionalDifficulty?: 0..3, schema: 1 }. /// AES-256-GCM via the shared Bytes codec; registered in ENCRYPTED_COLUMNS + /// the rotation script. The item-9 raw value lives ONLY here. - responsesEncrypted Bytes @map("responses_encrypted") + responsesEncrypted Bytes @map("responses_encrypted") /// Derived total (PHQ-9 0–27, GAD-7 0–21). Denormalised for fast reads. - totalScore Int @map("total_score") + totalScore Int @map("total_score") /// Derived severity band label (server-authoritative; descriptive, never a diagnosis). - severityBand String @map("severity_band") + severityBand String @map("severity_band") /// PHQ-9 only: item 9 (self-harm ideation) answered > 0. A coarse boolean so /// the history view can re-surface crisis resources without decrypting. - item9Flagged Boolean @default(false) @map("item9_flagged") + item9Flagged Boolean @default(false) @map("item9_flagged") /// Audit stamp: the supportive crisis-resource signpost was shown to the user. /// Never triggers any third-party alert — private, on-device-only signposting. - crisisShownAt DateTime? @map("crisis_shown_at") + crisisShownAt DateTime? @map("crisis_shown_at") /// Recall-window anchor (when the screener was taken). - takenAt DateTime @map("taken_at") - tz String? - source String @default("WEB") - externalId String? @map("external_id") - syncVersion Int @default(0) @map("sync_version") - deletedAt DateTime? @map("deleted_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + takenAt DateTime @map("taken_at") + tz String? + source String @default("WEB") + externalId String? @map("external_id") + syncVersion Int @default(0) @map("sync_version") + deletedAt DateTime? @map("deleted_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2537,26 +2649,26 @@ model MedicationInventoryItem { /// - (userId, occurredAt DESC) — Coach snapshot aggregation read /// path; correlation across medications is per-user, not per-drug. model MedicationSideEffect { - id String @id @default(cuid()) - userId String @map("user_id") - medicationId String @map("medication_id") - occurredAt DateTime @default(now()) @map("occurred_at") - category MedicationSideEffectCategory - entry MedicationSideEffectEntry + id String @id @default(cuid()) + userId String @map("user_id") + medicationId String @map("medication_id") + occurredAt DateTime @default(now()) @map("occurred_at") + category MedicationSideEffectCategory + entry MedicationSideEffectEntry /// 1 = mild, 2 = moderate, 3 = significant, 4 = severe, 5 = very-severe. /// Constrained to [1, 5] at the application layer (Zod) and the DB /// (CHECK constraint via migration). Not an enum to keep cardinality /// trivially numeric for chart aggregation. - severity Int + severity Int /// Optional free-text note, hard-capped to 280 chars at the /// application layer. The DB column is unbounded TEXT for /// consistency with sibling "notes" columns on this schema. - notes String? + notes String? /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column /// stays for read-fallback until the backfill reports zero remaining /// rows; the plaintext drop is DEFERRED to a later release. - notesEncrypted Bytes? @map("notes_encrypted") - createdAt DateTime @default(now()) @map("created_at") + notesEncrypted Bytes? @map("notes_encrypted") + createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@ -5254,28 +5366,28 @@ enum AllergyStatus { /// and notes are encrypted at rest. Patient-reported; the export carries a /// "not a clinical diagnosis" guard rail. model Allergy { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// User-facing substance/allergen name, e.g. "Penicillin", "Peanuts". /// Queryable plaintext label (the medication.name / illness.label /// precedent); rides FHIR `code.text`. - substance String - category AllergyCategory @default(OTHER) - type AllergyType @default(ALLERGY) + substance String + category AllergyCategory @default(OTHER) + type AllergyType @default(ALLERGY) /// Worst observed reaction severity. NULL = not assessed. - severity AllergySeverity? - status AllergyStatus @default(ACTIVE) + severity AllergySeverity? + status AllergyStatus @default(ACTIVE) /// When the allergy was first noticed (UTC instant). NULL = unknown. - onsetAt DateTime? @map("onset_at") + onsetAt DateTime? @map("onset_at") /// AES-256-GCM ciphertext of the free-text reaction/manifestation /// description (e.g. "anaphylaxis", "hives"). Bytes via the shared codec. - reactionEncrypted Bytes? @map("reaction_encrypted") + reactionEncrypted Bytes? @map("reaction_encrypted") /// AES-256-GCM ciphertext of an optional free-text note. Bytes column. - notesEncrypted Bytes? @map("notes_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + notesEncrypted Bytes? @map("notes_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -5312,20 +5424,20 @@ enum FamilyRelationship { /// code); `ageAtOnset` is the relative's age when the condition began. The /// optional free-text note is encrypted at rest. Patient-reported. model FamilyHistoryEntry { - id String @id @default(cuid()) - userId String @map("user_id") - relationship FamilyRelationship + id String @id @default(cuid()) + userId String @map("user_id") + relationship FamilyRelationship /// User-facing condition name, e.g. "Type 2 diabetes", "Breast cancer". /// Queryable plaintext label; rides FHIR `condition.code.text`. - condition String + condition String /// The relative's age (years) when the condition began. NULL = unknown. - ageAtOnset Int? @map("age_at_onset") + ageAtOnset Int? @map("age_at_onset") /// AES-256-GCM ciphertext of an optional free-text note. Bytes column. - notesEncrypted Bytes? @map("notes_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + notesEncrypted Bytes? @map("notes_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/environment/backfill/route.ts b/src/app/api/environment/backfill/route.ts new file mode 100644 index 000000000..61e6b117b --- /dev/null +++ b/src/app/api/environment/backfill/route.ts @@ -0,0 +1,89 @@ +/** + * `POST /api/environment/backfill` — fetch + store past days on demand. + * + * Enqueues a per-user fetch over the requested [startDate, endDate] so the user + * can populate history rather than waiting for the rolling nightly lookback. + * The span is capped (the worker re-checks the cap too). Requires a home + * location (otherwise there is nothing to resolve). Module-gated + rate-limited; + * `userId` is narrowed from auth. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { environmentBackfillSchema } from "@/lib/validations/environment"; +import { ENVIRONMENT_MAX_BACKFILL_DAYS } from "@/lib/environment/service"; +import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many requests. Please retry shortly.", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = environmentBackfillSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "environment.invalid", + }); + } + + const { startDate, endDate } = parsed.data; + const [sy, sm, sd] = startDate.split("-").map(Number); + const [ey, em, ed] = endDate.split("-").map(Number); + const spanDays = + Math.round( + (Date.UTC(ey, em - 1, ed) - Date.UTC(sy, sm - 1, sd)) / MS_PER_DAY, + ) + 1; + if (spanDays > ENVIRONMENT_MAX_BACKFILL_DAYS) { + return apiError( + `Backfill range too large (max ${ENVIRONMENT_MAX_BACKFILL_DAYS} days)`, + 422, + { errorCode: "environment.range_too_large" }, + ); + } + + const profile = await prisma.user.findUnique({ + where: { id: user.id }, + select: { homeLat: true }, + }); + if (profile?.homeLat == null) { + return apiError("Set a home location before backfilling.", 409, { + errorCode: "environment.no_home", + }); + } + + const enqueued = await enqueueEnvironmentFetch({ + userId: user.id, + startDate, + endDate, + }); + + annotate({ + action: { name: "environment.backfill.enqueue" }, + meta: { span_days: spanDays, enqueued }, + }); + + return apiSuccess({ enqueued, startDate, endDate, spanDays }, 202); +}); diff --git a/src/app/api/environment/geocode/route.ts b/src/app/api/environment/geocode/route.ts new file mode 100644 index 000000000..2fddc7b5f --- /dev/null +++ b/src/app/api/environment/geocode/route.ts @@ -0,0 +1,50 @@ +/** + * `GET /api/environment/geocode?q=` — forward-geocode a city to coarse matches. + * + * Proxies Open-Meteo's keyless geocoding through the server (so the browser + * never calls the third-party host and the CSP stays untouched) and returns + * coarse rounded coordinates + label + timezone for the home-location picker. + * Module-gated + rate-limited; `userId` is narrowed from auth. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { geocodeQuerySchema } from "@/lib/validations/environment"; +import { geocodeLocation } from "@/lib/environment/open-meteo"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + // Reuse the analytics-read budget: a city search is a cheap outbound call, + // but the generous bucket caps a runaway autocomplete loop. + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many requests. Please retry shortly.", 429); + } + + const params = new URL(request.url).searchParams; + const parsed = geocodeQuerySchema.safeParse({ q: params.get("q") ?? "" }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "environment.invalid", + }); + } + + const results = await geocodeLocation(parsed.data.q); + + annotate({ + action: { name: "environment.geocode.search" }, + meta: { result_count: results.length }, + }); + + return apiSuccess({ results }); +}); diff --git a/src/app/api/environment/home/route.ts b/src/app/api/environment/home/route.ts new file mode 100644 index 000000000..46bd6443e --- /dev/null +++ b/src/app/api/environment/home/route.ts @@ -0,0 +1,67 @@ +/** + * `PUT /api/environment/home` — set the account's coarse home location. + * + * The picked city (rounded lat/lon + label + IANA timezone) is stored on the + * user. Coordinates are rounded to ~city granularity here as a defence-in-depth + * floor even if a client sends finer values. Setting a home enqueues a + * lookback fetch so weather appears without waiting for the nightly tick. + * Module-gated; `userId` is narrowed from auth. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { homeLocationSchema } from "@/lib/validations/environment"; +import { roundCoarse } from "@/lib/environment/open-meteo"; +import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch"; + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = homeLocationSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "environment.home.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "environment.invalid", + }); + } + + const { lat, lon, label, timezone } = parsed.data; + const home = { + homeLat: roundCoarse(lat), + homeLon: roundCoarse(lon), + homeLabel: label, + homeTimezone: timezone, + }; + + await prisma.user.update({ where: { id: user.id }, data: home }); + + // Kick a lookback refresh so recent days populate promptly. No-ops cleanly + // when no worker is bound; the nightly cron still covers it. + await enqueueEnvironmentFetch({ userId: user.id }); + + annotate({ action: { name: "environment.home.set" } }); + + return apiSuccess({ + home: { + lat: home.homeLat, + lon: home.homeLon, + label: home.homeLabel, + timezone: home.homeTimezone, + }, + }); +}); diff --git a/src/app/api/environment/route.ts b/src/app/api/environment/route.ts new file mode 100644 index 000000000..3d393384c --- /dev/null +++ b/src/app/api/environment/route.ts @@ -0,0 +1,84 @@ +/** + * `GET /api/environment` — the environmental-context module overview. + * + * Returns the account's coarse home location, its travel overrides, a small + * summary of stored daily observations (count + latest day), and the upstream + * attribution string. Module-gated: a 403 `module.disabled` envelope when the + * opt-in module is off. `userId` is narrowed from auth, never a body field. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { OPEN_METEO_ATTRIBUTION } from "@/lib/environment/open-meteo"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const [profile, travel, contextCount, latest] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { + homeLat: true, + homeLon: true, + homeLabel: true, + homeTimezone: true, + timezone: true, + }, + }), + prisma.environmentTravelLocation.findMany({ + where: { userId: user.id }, + orderBy: { startDate: "desc" }, + select: { + id: true, + startDate: true, + endDate: true, + lat: true, + lon: true, + label: true, + }, + }), + prisma.environmentContext.count({ where: { userId: user.id } }), + prisma.environmentContext.findFirst({ + where: { userId: user.id }, + orderBy: { date: "desc" }, + select: { date: true, fetchedAt: true }, + }), + ]); + + const home = + profile?.homeLat != null && profile?.homeLon != null + ? { + lat: profile.homeLat, + lon: profile.homeLon, + label: profile.homeLabel, + timezone: profile.homeTimezone ?? profile.timezone, + } + : null; + + annotate({ + action: { name: "environment.overview.read" }, + meta: { + has_home: home !== null, + travel_count: travel.length, + context_days: contextCount, + }, + }); + + return apiSuccess({ + home, + travel, + context: { + days: contextCount, + latestDate: latest?.date ?? null, + latestFetchedAt: latest?.fetchedAt?.toISOString() ?? null, + }, + attribution: OPEN_METEO_ATTRIBUTION, + }); +}); diff --git a/src/app/api/environment/travel/[id]/route.ts b/src/app/api/environment/travel/[id]/route.ts new file mode 100644 index 000000000..ec0bd7c04 --- /dev/null +++ b/src/app/api/environment/travel/[id]/route.ts @@ -0,0 +1,44 @@ +/** + * `DELETE /api/environment/travel/[id]` — remove a manual travel override. + * + * Owner-scoped: the delete `where` carries both the id AND `userId` so one + * account can never delete another's override. Module-gated. A missing / already + * deleted row returns 404 rather than leaking existence. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; + +export const DELETE = apiHandler( + async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const { id } = await ctx.params; + + const result = await prisma.environmentTravelLocation.deleteMany({ + where: { id, userId: user.id }, + }); + if (result.count === 0) { + return apiError("Travel override not found", 404, { + errorCode: "environment.travel.not_found", + }); + } + + annotate({ + action: { + name: "environment.travel.delete", + entity_type: "environment_travel", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/environment/travel/route.ts b/src/app/api/environment/travel/route.ts new file mode 100644 index 000000000..6f5d31f6c --- /dev/null +++ b/src/app/api/environment/travel/route.ts @@ -0,0 +1,79 @@ +/** + * `POST /api/environment/travel` — add a manual travel override. + * + * For any day in [startDate, endDate] the weather fetch resolves against this + * coarse location instead of home (a declared trip dominates the home + * fallback). Coordinates are rounded to ~city granularity. Adding an override + * enqueues a lookback refresh so its days re-resolve. Module-gated; `userId` is + * narrowed from auth. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { travelLocationSchema } from "@/lib/validations/environment"; +import { roundCoarse } from "@/lib/environment/open-meteo"; +import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = travelLocationSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "environment.travel.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "environment.invalid", + }); + } + + const entry = parsed.data; + const created = await prisma.environmentTravelLocation.create({ + data: { + userId: user.id, + startDate: entry.startDate, + endDate: entry.endDate, + lat: roundCoarse(entry.lat), + lon: roundCoarse(entry.lon), + label: entry.label, + }, + select: { + id: true, + startDate: true, + endDate: true, + lat: true, + lon: true, + label: true, + }, + }); + + // Re-resolve the affected window so the override's days pick up its weather. + await enqueueEnvironmentFetch({ + userId: user.id, + startDate: entry.startDate, + endDate: entry.endDate, + }); + + annotate({ + action: { + name: "environment.travel.create", + entity_type: "environment_travel", + entity_id: created.id, + }, + }); + + return apiSuccess(created, 201); +}); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index a4d8b711f..a584897c9 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -20,6 +20,10 @@ vi.mock("@/lib/db", () => ({ illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) }, // v1.22 — labs ↔ outcome pass fetches lab draws; default empty. labResult: { findMany: vi.fn().mockResolvedValue([]) }, + // v1.25 (W-ENV) — the route folds the environmental-exposure behaviour + // channels in (read from EnvironmentContext); default empty so no env + // channel produces a point. + environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -113,6 +117,9 @@ beforeEach(() => { ); // v1.22 — labs ↔ outcome pass reads lab draws; default empty. (prisma.labResult.findMany as ReturnType).mockResolvedValue([]); + ( + prisma.environmentContext.findMany as ReturnType + ).mockResolvedValue([]); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 33cf3329a..4256e6274 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -39,6 +39,7 @@ import { } from "@/lib/insights/correlation-discovery"; import { fetchComplianceSeries, + fetchEnvironmentSeries, fetchLabDraws, fetchSymptomSeries, } from "@/lib/insights/correlation-channel-series"; @@ -152,11 +153,16 @@ export const GET = apiHandler(async () => { // their own sources. Each degrades to an empty series when the user has no // data, so the discovery loop drops the channel (it cannot clear n ≥ 20). // v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside. - const [complianceSeries, symptomSeries, labDraws] = await Promise.all([ - fetchComplianceSeries(user.id, tz, since), - fetchSymptomSeries(user.id, tz, since), - fetchLabDraws(user.id, tz, since), - ]); + const [complianceSeries, symptomSeries, labDraws, environmentSeries] = + await Promise.all([ + fetchComplianceSeries(user.id, tz, since), + fetchSymptomSeries(user.id, tz, since), + fetchLabDraws(user.id, tz, since), + // v1.25 (W-ENV) — environmental-exposure behaviour channels (weather / + // daylight). Empty when the module is off / no home set, so the channels + // degrade to absent. The module gate is implicit: no rows ⇒ no channels. + fetchEnvironmentSeries(user.id, since), + ]); const points = (key: string): DailySeriesPoint[] => key === "MOOD" @@ -180,6 +186,12 @@ export const GET = apiHandler(async () => { series.push({ key, role: "outcome", points: points(key) }); } } + // v1.25 (W-ENV) — fold the environmental-exposure behaviour channels in. They + // pair (lag D → D+1) against every outcome above; the n ≥ 20 / FDR / effect- + // size gates apply unchanged, so a thin weather series degrades to absent. + for (const envSeries of environmentSeries) { + series.push(envSeries); + } const result = discoverCorrelations(series); @@ -213,6 +225,13 @@ export const GET = apiHandler(async () => { emerging_window_days: emerging.windowDays, lab_draws: labDraws.length, lab_correlations: labCorrelations.discovered.length, + // v1.25 (W-ENV) — env channel reach (sum of stored daily points across + // the exposure channels) so a dashboard can see whether weather was + // available for the scan. + environment_days: environmentSeries.reduce( + (sum, s) => sum + s.points.length, + 0, + ), }, }); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 2959110f4..3b8d0b82a 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -10,6 +10,7 @@ import { AiSection } from "@/components/settings/ai-section"; import { ApiSection } from "@/components/settings/api-section"; import { CoachSection } from "@/components/settings/coach-section"; import { DashboardSection } from "@/components/settings/dashboard-section"; +import { EnvironmentSection } from "@/components/settings/environment-section"; import { ExportSection } from "@/components/settings/export-section"; import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section"; import { IllnessSection } from "@/components/settings/illness-section"; @@ -75,6 +76,7 @@ const SECTION_COMPONENTS: Record< mood: MoodSection, labs: LabsSection, illness: IllnessSection, + environment: EnvironmentSection, anamnesis: AnamnesisSection, vorsorge: VorsorgeSection, thresholds: ThresholdsSection, diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 4b2283d96..b56b70d1e 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -52,6 +52,15 @@ const CHANNEL_LABEL_KEY: Record = { HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", RESTING_HEART_RATE: "measurements.typeRestingHeartRate", WEIGHT: "measurements.typeWeight", + // v1.25 (W-ENV) — environmental-exposure channels, so a "daylight → mood" + // relation reads with its descriptive label instead of the raw ENV_ key. + ENV_TEMP_MEAN: "environment.fields.tempMean", + ENV_TEMP_MIN: "environment.fields.tempMin", + ENV_SUNSHINE: "environment.fields.sunshine", + ENV_DAYLIGHT: "environment.fields.daylight", + ENV_PRECIP: "environment.fields.precip", + ENV_PRESSURE_MEAN: "environment.fields.pressureMean", + ENV_PRESSURE_DELTA: "environment.fields.pressureDelta", }; /** Keep only the discovered pairs that involve the mood channel. */ diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 60f1947e2..004d13aed 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -112,6 +112,9 @@ describe("SETTINGS_SECTION_SLUGS", () => { "mood", "labs", "illness", + // v1.25 (W-ENV) — Environment (home location, travel overrides, backfill), + // module-gated on the opt-in `environment` module. + "environment", // v1.25 (W-RECORDS) — Anamnese (allergies + family history), always shown. "anamnesis", "vorsorge", diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx new file mode 100644 index 000000000..68cc87b37 --- /dev/null +++ b/src/components/settings/environment-section.tsx @@ -0,0 +1,440 @@ +"use client"; + +/** + * `` — Settings → Environment (v1.25, W-ENV). + * + * The one surface for the opt-in environmental-context module: pick a coarse + * home location (city search → rounded lat/lon, never a GPS track), declare + * travel overrides that win over home for their date range, and trigger a + * backfill of past days. The nightly job keeps recent days current; the + * correlation engine then surfaces e.g. "daylight ↔ mood" on its own. + * + * State source for the enabled flag is `useAuth().user.modules.environment` + * (the resolved /auth/me map). When the module is off the section nudges the + * user to the Modules hub rather than rendering dead controls. All reads unwrap + * `(await res.json()).data` via the api-fetch helpers; every key routes through + * the centralized factory. + */ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { CloudSun, MapPin, Plane, Trash2 } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +interface GeocodeResult { + lat: number; + lon: number; + label: string; + timezone: string; +} + +interface TravelDTO { + id: string; + startDate: string; + endDate: string; + lat: number; + lon: number; + label: string; +} + +interface EnvironmentOverview { + home: { + lat: number; + lon: number; + label: string | null; + timezone: string; + } | null; + travel: TravelDTO[]; + context: { + days: number; + latestDate: string | null; + latestFetchedAt: string | null; + }; + attribution: string; +} + +function todayKey(): string { + return new Date().toISOString().slice(0, 10); +} + +export function EnvironmentSection() { + const { t } = useTranslations(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + + const enabled = user?.modules?.environment === true; + + const overview = useQuery({ + queryKey: queryKeys.environment(), + enabled, + queryFn: () => apiGet("/api/environment"), + }); + + const invalidate = () => + void queryClient.invalidateQueries({ queryKey: queryKeys.environment() }); + + // ── geocoding search (shared by home + travel pickers) ────────────────── + const [homeQuery, setHomeQuery] = useState(""); + const [homeResults, setHomeResults] = useState([]); + const [travelQuery, setTravelQuery] = useState(""); + const [travelResults, setTravelResults] = useState([]); + const [travelLoc, setTravelLoc] = useState(null); + const [travelStart, setTravelStart] = useState(todayKey()); + const [travelEnd, setTravelEnd] = useState(todayKey()); + const [backfillStart, setBackfillStart] = useState(""); + const [backfillEnd, setBackfillEnd] = useState(todayKey()); + + const geocode = (query: string) => + apiGet<{ results: GeocodeResult[] }>( + `/api/environment/geocode?q=${encodeURIComponent(query)}`, + ); + + const searchHome = useMutation({ + mutationKey: queryKeys.environmentGeocode("home"), + mutationFn: () => geocode(homeQuery), + onSuccess: (data) => setHomeResults(data.results), + onError: () => toast.error(t("settings.sections.environment.searchError")), + }); + + const searchTravel = useMutation({ + mutationKey: queryKeys.environmentGeocode("travel"), + mutationFn: () => geocode(travelQuery), + onSuccess: (data) => setTravelResults(data.results), + onError: () => toast.error(t("settings.sections.environment.searchError")), + }); + + const setHome = useMutation({ + mutationKey: queryKeys.environment(), + mutationFn: (loc: GeocodeResult) => + apiPut("/api/environment/home", { + lat: loc.lat, + lon: loc.lon, + label: loc.label, + timezone: loc.timezone, + }), + onSuccess: () => { + setHomeResults([]); + setHomeQuery(""); + invalidate(); + toast.success(t("settings.sections.environment.homeSaved")); + }, + onError: () => toast.error(t("settings.sections.environment.saveError")), + }); + + const addTravel = useMutation({ + mutationKey: queryKeys.environment(), + mutationFn: () => + apiPost("/api/environment/travel", { + startDate: travelStart, + endDate: travelEnd, + lat: travelLoc!.lat, + lon: travelLoc!.lon, + label: travelLoc!.label, + }), + onSuccess: () => { + setTravelLoc(null); + setTravelResults([]); + setTravelQuery(""); + invalidate(); + toast.success(t("settings.sections.environment.travelAdded")); + }, + onError: () => toast.error(t("settings.sections.environment.saveError")), + }); + + const removeTravel = useMutation({ + mutationKey: queryKeys.environment(), + mutationFn: (id: string) => apiDelete(`/api/environment/travel/${id}`), + onSuccess: () => { + invalidate(); + toast.success(t("settings.sections.environment.travelRemoved")); + }, + onError: () => toast.error(t("settings.sections.environment.saveError")), + }); + + const backfill = useMutation({ + mutationKey: queryKeys.environment(), + mutationFn: () => + apiPost("/api/environment/backfill", { + startDate: backfillStart, + endDate: backfillEnd, + }), + onSuccess: () => { + invalidate(); + toast.success(t("settings.sections.environment.backfillQueued")); + }, + onError: () => + toast.error(t("settings.sections.environment.backfillError")), + }); + + if (!enabled) { + return ( + + +

+ {t("settings.sections.environment.disabledHint")} +

+
+ ); + } + + const data = overview.data; + const home = data?.home ?? null; + + return ( +
+ {/* Home location */} + + +

+ {t("settings.sections.environment.home.description")} +

+
+ {home ? ( +

+ {t("settings.sections.environment.home.current", { + label: home.label ?? `${home.lat}, ${home.lon}`, + })} +

+ ) : ( +

+ {t("settings.sections.environment.home.none")} +

+ )} +
+ setHomeQuery(e.target.value)} + placeholder={t("settings.sections.environment.searchPlaceholder")} + aria-label={t("settings.sections.environment.searchPlaceholder")} + /> + +
+ {homeResults.length > 0 && ( +
    + {homeResults.map((r) => ( +
  • + {r.label} + +
  • + ))} +
+ )} +
+
+ + {/* Travel overrides */} + + +

+ {t("settings.sections.environment.travel.description")} +

+
+ {data && data.travel.length > 0 ? ( +
    + {data.travel.map((tr) => ( +
  • + + {tr.label} · {tr.startDate} – {tr.endDate} + + +
  • + ))} +
+ ) : ( +

+ {t("settings.sections.environment.travel.none")} +

+ )} + +
+ setTravelQuery(e.target.value)} + placeholder={t("settings.sections.environment.searchPlaceholder")} + aria-label={t("settings.sections.environment.searchPlaceholder")} + /> + +
+ {travelResults.length > 0 && ( +
    + {travelResults.map((r) => ( +
  • + {r.label} + +
  • + ))} +
+ )} + {travelLoc && ( +
+
+ + setTravelStart(e.target.value)} + /> +
+
+ + setTravelEnd(e.target.value)} + /> +
+ +
+ )} +
+
+ + {/* Backfill */} + + +

+ {t("settings.sections.environment.backfill.description")} +

+
+
+ + setBackfillStart(e.target.value)} + /> +
+
+ + setBackfillEnd(e.target.value)} + /> +
+ +
+ {data && ( +

+ {t("settings.sections.environment.storedDays", { + count: data.context.days, + })} +

+ )} +
+ + {/* Attribution (CC BY 4.0, required) */} +

+ {data?.attribution ?? "Weather data by Open-Meteo.com"} +

+
+ ); +} diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index f6c5a5f43..3dc727b67 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -30,6 +30,7 @@ import { Blocks, CalendarHeart, Droplet, + CloudSun, Dumbbell, FileText, MessageCircleHeart, @@ -72,6 +73,8 @@ const MODULE_ICONS: Record = { // v1.18.1 (D3) — medications graduated from CORE to a toggleable module. medications: Pill, doctorReport: FileText, + // v1.25.0 — the environmental-context module (opt-in weather/daylight feed). + environment: CloudSun, // v1.22.0 — the remote MCP endpoint (opt-in connectivity module). mcp: Plug, }; diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index eefcf76ce..8b6b05c6b 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -16,6 +16,7 @@ import { Blocks, Bot, CalendarCheck, + CloudSun, Download, ClipboardList, FileHeart, @@ -60,6 +61,7 @@ const SLUG_ICON: Record = { mood: Smile, labs: FlaskConical, illness: Thermometer, + environment: CloudSun, anamnesis: ClipboardList, vorsorge: CalendarCheck, thresholds: SlidersHorizontal, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 84872348c..8483253ef 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -69,6 +69,10 @@ export const SETTINGS_SECTION_SLUGS = [ "mood", "labs", "illness", + // v1.25 (W-ENV) — "Umwelt" (environmental context): home location, travel + // overrides, and the weather/daylight backfill. Module-gated on the opt-in + // `environment` module (the nav entry only shows when it is on). + "environment", // v1.25 (W-RECORDS) — "Anamnese" (medical history): the structured-record // home for allergies + family history. Always shown (not module-gated, like // `vorsorge`); the records are foundational health-profile data. diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 85c7838ba..37bc06cfc 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -23,6 +23,7 @@ import { Bot, CalendarCheck, ClipboardList, + CloudSun, Download, FileHeart, FlaskConical, @@ -192,6 +193,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ icon: Thermometer, moduleGate: "illness", }, + // v1.25 (W-ENV) — Environmental context: home location, travel overrides, and + // the weather/daylight backfill. Module-gated on the opt-in `environment` + // module, so the entry only appears once the user turns it on. + { + slug: "environment", + titleKey: "settings.sections.environment.title", + icon: CloudSun, + moduleGate: "environment", + }, // v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record home // for allergies + family history. NOT module-gated (like Vorsorge) — these // are foundational health-profile records, always available. Sits between diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index d1939cf09..06cf4bf43 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -83,6 +83,7 @@ function moduleMap( insights: true, medications: true, doctorReport: true, + environment: true, mcp: true, }; return { ...base, ...overrides }; diff --git a/src/lib/environment/fields.ts b/src/lib/environment/fields.ts new file mode 100644 index 000000000..70e94d6cd --- /dev/null +++ b/src/lib/environment/fields.ts @@ -0,0 +1,131 @@ +/** + * v1.25 (W-ENV) — the canonical environmental-exposure field vocabulary. + * + * One declarative table that names each daily weather/daylight field HealthLog + * stores AND correlates. It is the single source the two surfaces speak: + * + * - the SIGNAL REGISTRY (`src/lib/signals/registry.ts`) registers one + * `kind:"environment"` signal per field here, so each gets a stable signal + * key + display metadata for free; and + * - the CORRELATION ENGINE reads {@link ENVIRONMENT_CHANNEL_KEYS} to fold the + * same fields in as lagged BEHAVIOUR (exposure) channels against mood / + * sleep / vitals. + * + * `key` doubles as the registry signal key AND the correlation channel key — + * the `ENV_` prefix keeps it from colliding with a `MeasurementType` and lets + * `metricFamily()` collapse all env channels into one `ENVIRONMENT` family (so + * the engine never lag-correlates two same-day weather fields against each + * other — that is a near-tautology, not an insight). + * + * `column` is the `EnvironmentContext` numeric column the field reads. Leaf + * module (no imports) so both the registry and the pure correlation engine can + * depend on it without a cycle. + */ + +/** The `EnvironmentContext` numeric columns a correlation field can read. */ +export type EnvironmentContextNumericColumn = + | "tempMean" + | "tempMin" + | "tempMax" + | "apparentMean" + | "sunshineSec" + | "daylightSec" + | "precipSum" + | "pressureMean" + | "pressureDelta" + | "humidityMean" + | "cloudMean"; + +export interface EnvironmentField { + /** Stable key — the registry signal key AND the correlation channel key. */ + key: string; + /** The `EnvironmentContext` column this field's daily value comes from. */ + column: EnvironmentContextNumericColumn; + /** Canonical unit (English, for prose + registry metadata). */ + unit: string; + /** Stable English display name (the UI localises via `i18nLabelKey`). */ + displayName: string; + /** Lower-cased phrase the correlation narration ("humanise") reads. */ + narrationLabel: string; + /** i18n label key resolved by the correlation / settings surfaces. */ + i18nLabelKey: string; +} + +/** + * The exposure fields, ranked by evidence in the W-ENV research review: + * temperature (sleep / BP / recovery), sunshine + daylight (mood), precipitation + * (activity), pressure mean + intraday swing (headache/symptom — honestly + * caveated, optional-strength). All are BEHAVIOUR (lag-source) channels. + */ +export const ENVIRONMENT_FIELDS: readonly EnvironmentField[] = [ + { + key: "ENV_TEMP_MEAN", + column: "tempMean", + unit: "°C", + displayName: "Daily temperature", + narrationLabel: "daily temperature", + i18nLabelKey: "environment.fields.tempMean", + }, + { + key: "ENV_TEMP_MIN", + column: "tempMin", + unit: "°C", + displayName: "Overnight low temperature", + narrationLabel: "overnight low temperature", + i18nLabelKey: "environment.fields.tempMin", + }, + { + key: "ENV_SUNSHINE", + column: "sunshineSec", + unit: "h", + displayName: "Sunshine duration", + narrationLabel: "sunshine", + i18nLabelKey: "environment.fields.sunshine", + }, + { + key: "ENV_DAYLIGHT", + column: "daylightSec", + unit: "h", + displayName: "Daylight length", + narrationLabel: "daylight", + i18nLabelKey: "environment.fields.daylight", + }, + { + key: "ENV_PRECIP", + column: "precipSum", + unit: "mm", + displayName: "Precipitation", + narrationLabel: "precipitation", + i18nLabelKey: "environment.fields.precip", + }, + { + key: "ENV_PRESSURE_MEAN", + column: "pressureMean", + unit: "hPa", + displayName: "Barometric pressure", + narrationLabel: "barometric pressure", + i18nLabelKey: "environment.fields.pressureMean", + }, + { + key: "ENV_PRESSURE_DELTA", + column: "pressureDelta", + unit: "hPa", + displayName: "Pressure swing", + narrationLabel: "intraday pressure swing", + i18nLabelKey: "environment.fields.pressureDelta", + }, +] as const; + +/** The correlation channel keys, in declaration order. */ +export const ENVIRONMENT_CHANNEL_KEYS: readonly string[] = + ENVIRONMENT_FIELDS.map((f) => f.key); + +/** True for a registered environmental channel key. */ +export function isEnvironmentChannelKey(key: string): boolean { + return ENVIRONMENT_CHANNEL_KEYS.includes(key); +} + +/** Resolve a field by its key, or null when unregistered. */ +export function getEnvironmentField(key: string): EnvironmentField | null { + return ENVIRONMENT_FIELDS.find((f) => f.key === key) ?? null; +} diff --git a/src/lib/environment/open-meteo.ts b/src/lib/environment/open-meteo.ts new file mode 100644 index 000000000..87ab829e6 --- /dev/null +++ b/src/lib/environment/open-meteo.ts @@ -0,0 +1,290 @@ +/** + * v1.25 (W-ENV) — Open-Meteo client (keyless historical weather + geocoding). + * + * The single outbound egress for the environmental-context module. Open-Meteo's + * Historical (Archive) API is keyless, free for non-commercial use, and offers + * a clean per-date global lat/lon endpoint — the right fit for a self-host- + * friendly, privacy-first exposure feed. Data is CC BY 4.0 ("Weather data by + * Open-Meteo.com" — credited on the settings surface). + * + * Egress goes through `safeFetch` (the documented wrapper: manual redirects + + * a request timeout). The base URL is operator config, not arbitrary user + * input, so `requirePublicHost` is deliberately NOT forced — a privacy- + * maximalist operator can point `OPENMETEO_BASE_URL` at a self-hosted Open-Meteo + * instance on a private/LAN host without it being blocked. The hosted default is + * public. + * + * Server-side only. The archive feed lags real time by a few days (reanalysis + * settling), so callers fetch with a lookback window rather than "yesterday + * only" — see the nightly job and the backfill route. + */ +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; + +/** Hosted archive default; override with `OPENMETEO_BASE_URL` (self-host). */ +const ARCHIVE_BASE_URL = + process.env.OPENMETEO_BASE_URL?.replace(/\/$/, "") ?? + "https://archive-api.open-meteo.com"; + +/** Hosted geocoding default; override with `OPENMETEO_GEOCODING_URL`. */ +const GEOCODING_BASE_URL = + process.env.OPENMETEO_GEOCODING_URL?.replace(/\/$/, "") ?? + "https://geocoding-api.open-meteo.com"; + +const FETCH_TIMEOUT_MS = 15_000; + +/** CC BY 4.0 attribution required by Open-Meteo; surfaced on the settings UI. */ +export const OPEN_METEO_ATTRIBUTION = "Weather data by Open-Meteo.com"; + +/** One geocoding match — coarse city placement for the home-location picker. */ +export interface GeocodeResult { + /** Rounded coarse latitude (2 dp — ~1 km, the privacy floor). */ + lat: number; + /** Rounded coarse longitude (2 dp). */ + lon: number; + /** Human label, e.g. "Bochum, North Rhine-Westphalia, Germany". */ + label: string; + /** IANA timezone for the place (anchors the day-key of stored rows). */ + timezone: string; +} + +/** A single day's resolved weather observation, mapped to our column shape. */ +export interface DailyEnvironmentObservation { + /** YYYY-MM-DD in the requested timezone. */ + date: string; + tempMin: number | null; + tempMax: number | null; + tempMean: number | null; + apparentMean: number | null; + sunshineSec: number | null; + daylightSec: number | null; + precipSum: number | null; + pressureMean: number | null; + pressureDelta: number | null; + humidityMean: number | null; + cloudMean: number | null; + weatherCode: number | null; +} + +/** Round a coordinate to 2 decimals (~1 km) — the coarse-location privacy floor. */ +export function roundCoarse(value: number): number { + return Math.round(value * 100) / 100; +} + +function firstNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** Mean of the finite numbers in `xs`, or null when none. */ +function meanOf(xs: number[]): number | null { + if (xs.length === 0) return null; + const sum = xs.reduce((a, b) => a + b, 0); + return sum / xs.length; +} + +interface ArchiveDaily { + time?: string[]; + temperature_2m_min?: (number | null)[]; + temperature_2m_max?: (number | null)[]; + temperature_2m_mean?: (number | null)[]; + apparent_temperature_mean?: (number | null)[]; + sunshine_duration?: (number | null)[]; + daylight_duration?: (number | null)[]; + precipitation_sum?: (number | null)[]; + weather_code?: (number | null)[]; +} + +interface ArchiveHourly { + time?: string[]; + surface_pressure?: (number | null)[]; + relative_humidity_2m?: (number | null)[]; + cloud_cover?: (number | null)[]; +} + +interface ArchiveResponse { + daily?: ArchiveDaily; + hourly?: ArchiveHourly; +} + +const DAILY_FIELDS = [ + "temperature_2m_min", + "temperature_2m_max", + "temperature_2m_mean", + "apparent_temperature_mean", + "sunshine_duration", + "daylight_duration", + "precipitation_sum", + "weather_code", +].join(","); + +const HOURLY_FIELDS = [ + "surface_pressure", + "relative_humidity_2m", + "cloud_cover", +].join(","); + +/** Per-day aggregation accumulator for the hourly-only fields. */ +interface HourlyDayAgg { + pressures: number[]; + humidities: number[]; + clouds: number[]; +} + +/** + * Aggregate the hourly feed (pressure / humidity / cloud — not available as a + * daily aggregate from the archive) into per-day values: mean for each, plus the + * intraday pressure delta (max − min), the derived headache/symptom feature. + */ +function aggregateHourly(hourly: ArchiveHourly): Map { + const byDay = new Map(); + const times = hourly.time ?? []; + for (let i = 0; i < times.length; i++) { + const day = times[i]?.slice(0, 10); + if (!day) continue; + const agg = byDay.get(day) ?? { pressures: [], humidities: [], clouds: [] }; + const p = firstNumber(hourly.surface_pressure?.[i]); + const h = firstNumber(hourly.relative_humidity_2m?.[i]); + const c = firstNumber(hourly.cloud_cover?.[i]); + if (p !== null) agg.pressures.push(p); + if (h !== null) agg.humidities.push(h); + if (c !== null) agg.clouds.push(c); + byDay.set(day, agg); + } + return byDay; +} + +/** + * Forward-geocode a free-text place to coarse matches. Keyless. Returns at most + * `count` results; an empty array when nothing matched or the feed errored + * (the caller surfaces "no match" rather than failing the request). + */ +export async function geocodeLocation( + query: string, + count = 5, +): Promise { + const trimmed = query.trim(); + if (trimmed.length === 0) return []; + + const url = new URL(`${GEOCODING_BASE_URL}/v1/search`); + url.searchParams.set("name", trimmed); + url.searchParams.set("count", String(Math.min(Math.max(count, 1), 10))); + url.searchParams.set("format", "json"); + + let res: Response; + try { + res = await safeFetch( + url, + { headers: { accept: "application/json" } }, + { + timeoutMs: FETCH_TIMEOUT_MS, + }, + ); + } catch (err) { + if (err instanceof SafeFetchError) return []; + throw err; + } + if (!res.ok) return []; + + const body = (await res.json()) as { + results?: Array<{ + name?: string; + latitude?: number; + longitude?: number; + country?: string; + admin1?: string; + timezone?: string; + }>; + }; + + const out: GeocodeResult[] = []; + for (const r of body.results ?? []) { + if (typeof r.latitude !== "number" || typeof r.longitude !== "number") { + continue; + } + const parts = [r.name, r.admin1, r.country].filter( + (p): p is string => typeof p === "string" && p.length > 0, + ); + out.push({ + lat: roundCoarse(r.latitude), + lon: roundCoarse(r.longitude), + label: parts.join(", "), + timezone: + typeof r.timezone === "string" && r.timezone.length > 0 + ? r.timezone + : "auto", + }); + } + return out; +} + +/** + * Fetch the daily environment observations for a coarse location over a date + * range (inclusive YYYY-MM-DD). The hourly-only fields (pressure / humidity / + * cloud) are requested alongside and aggregated to per-day values here. Throws + * a {@link SafeFetchError} on egress failure so the caller can classify it. + */ +export async function fetchDailyEnvironment(args: { + lat: number; + lon: number; + timezone: string; + startDate: string; + endDate: string; +}): Promise { + const url = new URL(`${ARCHIVE_BASE_URL}/v1/archive`); + url.searchParams.set("latitude", String(args.lat)); + url.searchParams.set("longitude", String(args.lon)); + url.searchParams.set("start_date", args.startDate); + url.searchParams.set("end_date", args.endDate); + url.searchParams.set("daily", DAILY_FIELDS); + url.searchParams.set("hourly", HOURLY_FIELDS); + url.searchParams.set("timezone", args.timezone); + + const res = await safeFetch( + url, + { headers: { accept: "application/json" } }, + { + timeoutMs: FETCH_TIMEOUT_MS, + }, + ); + if (!res.ok) { + throw new SafeFetchError( + `open-meteo archive returned HTTP ${res.status}`, + "network", + ); + } + + const body = (await res.json()) as ArchiveResponse; + const daily = body.daily ?? {}; + const days = daily.time ?? []; + const hourlyByDay = aggregateHourly(body.hourly ?? {}); + + const observations: DailyEnvironmentObservation[] = []; + for (let i = 0; i < days.length; i++) { + const date = days[i]; + if (!date) continue; + const agg = hourlyByDay.get(date); + const pressures = agg?.pressures ?? []; + const pressureMean = meanOf(pressures); + const pressureDelta = + pressures.length > 0 + ? Math.max(...pressures) - Math.min(...pressures) + : null; + + observations.push({ + date, + tempMin: firstNumber(daily.temperature_2m_min?.[i]), + tempMax: firstNumber(daily.temperature_2m_max?.[i]), + tempMean: firstNumber(daily.temperature_2m_mean?.[i]), + apparentMean: firstNumber(daily.apparent_temperature_mean?.[i]), + sunshineSec: firstNumber(daily.sunshine_duration?.[i]), + daylightSec: firstNumber(daily.daylight_duration?.[i]), + precipSum: firstNumber(daily.precipitation_sum?.[i]), + pressureMean, + pressureDelta: + pressureDelta !== null ? Math.round(pressureDelta * 10) / 10 : null, + humidityMean: meanOf(agg?.humidities ?? []), + cloudMean: meanOf(agg?.clouds ?? []), + weatherCode: firstNumber(daily.weather_code?.[i]), + }); + } + return observations; +} diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts new file mode 100644 index 000000000..59962bf73 --- /dev/null +++ b/src/lib/environment/service.ts @@ -0,0 +1,239 @@ +/** + * v1.25 (W-ENV) — environmental-context resolution + fetch/upsert service. + * + * Owns the location-precedence logic and the per-day upsert into + * `EnvironmentContext`. Both the nightly job and the on-demand backfill route + * call {@link fetchAndStoreEnvironment} so the precedence, the day-keying, and + * the upsert live in exactly one place. + * + * Location precedence per day (first hit wins): + * 1. a manual TRAVEL override whose [startDate, endDate] covers the day; + * 2. the user's HOME location. + * No home + no covering override ⇒ the day is skipped (the module degrades + * silently, matching the rollup "coverage miss → absent" posture). + * + * Coarse location only — the resolver never sees finer than the rounded city + * coordinates stored on the user / override. + */ +import { prisma } from "@/lib/db"; +import { + fetchDailyEnvironment, + type DailyEnvironmentObservation, +} from "@/lib/environment/open-meteo"; +import type { EnvironmentLocationSource } from "@/generated/prisma/client"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** Default lookback window (days) for the nightly fetch — absorbs the archive + * settling lag and any days missed across worker reboots. */ +export const ENVIRONMENT_LOOKBACK_DAYS = 7; +/** Hard cap on a single backfill span so one request can never fan out forever. */ +export const ENVIRONMENT_MAX_BACKFILL_DAYS = 730; + +/** A coarse resolved location for a given day. */ +interface ResolvedLocation { + lat: number; + lon: number; + label: string; + source: EnvironmentLocationSource; +} + +interface HomeLocation { + lat: number; + lon: number; + label: string; + timezone: string; +} + +interface TravelOverride { + startDate: string; + endDate: string; + lat: number; + lon: number; + label: string; +} + +/** UTC YYYY-MM-DD for `date` (used to bound the default lookback window). */ +export function utcDayKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** Inclusive list of YYYY-MM-DD keys from `start` to `end` (date arithmetic). */ +export function enumerateDays(start: string, end: string): string[] { + const out: string[] = []; + const [sy, sm, sd] = start.split("-").map(Number); + const [ey, em, ed] = end.split("-").map(Number); + let cur = Date.UTC(sy, sm - 1, sd); + const last = Date.UTC(ey, em - 1, ed); + while (cur <= last) { + out.push(new Date(cur).toISOString().slice(0, 10)); + cur += MS_PER_DAY; + } + return out; +} + +/** + * Resolve the location for one day: a covering travel override wins, else home, + * else null. Pure — exported for unit tests. + */ +export function resolveLocationForDay( + day: string, + home: HomeLocation | null, + travels: readonly TravelOverride[], +): ResolvedLocation | null { + for (const t of travels) { + if (day >= t.startDate && day <= t.endDate) { + return { lat: t.lat, lon: t.lon, label: t.label, source: "TRAVEL" }; + } + } + if (home) { + return { lat: home.lat, lon: home.lon, label: home.label, source: "HOME" }; + } + return null; +} + +/** A location-keyed group of days (one upstream fetch per group). */ +function groupKey(loc: ResolvedLocation): string { + return `${loc.lat},${loc.lon},${loc.source},${loc.label}`; +} + +export interface FetchAndStoreResult { + /** Days that resolved to a location and were upserted. */ + stored: number; + /** Days skipped because no location resolved (no home, no override). */ + skipped: number; + /** Distinct upstream fetches made. */ + fetches: number; +} + +/** + * Resolve + fetch + upsert the environment rows for a user across a date range. + * Groups days by resolved location so each location is fetched once over its + * contiguous span. A day whose observation is absent from the feed is left + * un-stored (no fabricated row). Idempotent — re-running upserts the same rows. + */ +export async function fetchAndStoreEnvironment(args: { + userId: string; + startDate: string; + endDate: string; +}): Promise { + const { userId, startDate, endDate } = args; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + homeLat: true, + homeLon: true, + homeLabel: true, + homeTimezone: true, + timezone: true, + }, + }); + if (!user) return { stored: 0, skipped: 0, fetches: 0 }; + + const home: HomeLocation | null = + user.homeLat != null && user.homeLon != null + ? { + lat: user.homeLat, + lon: user.homeLon, + label: user.homeLabel ?? "Home", + timezone: user.homeTimezone ?? user.timezone, + } + : null; + + const travelRows = await prisma.environmentTravelLocation.findMany({ + where: { userId }, + select: { + startDate: true, + endDate: true, + lat: true, + lon: true, + label: true, + }, + }); + + // The timezone used to enumerate AND fetch days — one tz keeps the stored + // `date` consistent with the user's day-keys elsewhere. + const timezone = home?.timezone ?? user.timezone; + + const days = enumerateDays(startDate, endDate); + const resolvedByDay = new Map(); + const groups = new Map(); + let skipped = 0; + for (const day of days) { + const loc = resolveLocationForDay(day, home, travelRows); + if (!loc) { + skipped += 1; + continue; + } + resolvedByDay.set(day, loc); + const gk = groupKey(loc); + const group = groups.get(gk) ?? { loc, days: [] }; + group.days.push(day); + groups.set(gk, group); + } + + let stored = 0; + let fetches = 0; + for (const { loc, days: groupDays } of groups.values()) { + const min = groupDays[0]; + const max = groupDays[groupDays.length - 1]; + let observations: DailyEnvironmentObservation[]; + try { + observations = await fetchDailyEnvironment({ + lat: loc.lat, + lon: loc.lon, + timezone, + startDate: min, + endDate: max, + }); + } catch { + // A failed fetch for one location leaves its days un-stored; the nightly + // lookback re-attempts them. Other locations still get their rows. + continue; + } + fetches += 1; + + const obsByDate = new Map(observations.map((o) => [o.date, o])); + const wanted = new Set(groupDays); + for (const obs of observations) { + if (!wanted.has(obs.date)) continue; + await upsertObservation(userId, loc, obs); + stored += 1; + } + // Days the feed did not return (e.g. beyond the settling lag) stay absent. + void obsByDate; + } + + return { stored, skipped, fetches }; +} + +async function upsertObservation( + userId: string, + loc: ResolvedLocation, + obs: DailyEnvironmentObservation, +): Promise { + const data = { + lat: loc.lat, + lon: loc.lon, + locationLabel: loc.label, + source: loc.source, + tempMin: obs.tempMin, + tempMax: obs.tempMax, + tempMean: obs.tempMean, + apparentMean: obs.apparentMean, + sunshineSec: obs.sunshineSec != null ? Math.round(obs.sunshineSec) : null, + daylightSec: obs.daylightSec != null ? Math.round(obs.daylightSec) : null, + precipSum: obs.precipSum, + pressureMean: obs.pressureMean, + pressureDelta: obs.pressureDelta, + humidityMean: obs.humidityMean, + cloudMean: obs.cloudMean, + weatherCode: obs.weatherCode, + fetchedAt: new Date(), + }; + await prisma.environmentContext.upsert({ + where: { userId_date: { userId, date: obs.date } }, + create: { userId, date: obs.date, ...data }, + update: data, + }); +} diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 98751db43..fa3d4f90e 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -24,11 +24,13 @@ import { import { MEDICATION_COMPLIANCE_CHANNEL_KEY, SYMPTOM_SEVERITY_CHANNEL_KEY, + type DailySeriesPoint, type LabDrawPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { resolveLabFields } from "@/lib/labs/serialise"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields"; import { buildComplianceDailySeries, buildSymptomSeverityDailySeries, @@ -183,6 +185,59 @@ export async function fetchSymptomSeries( }); } +/** + * v1.25 (W-ENV) — build the user's environmental-exposure BEHAVIOUR channels. + * + * One {@link NamedSeries} per registered env field (temperature, daylight, + * sunshine, precipitation, pressure mean + intraday swing), read from the daily + * `EnvironmentContext` rows the nightly job stores. Each row's `date` is already + * a YYYY-MM-DD key, so points need no re-keying. Sunshine / daylight are stored + * in seconds and surfaced as hours (correlation r is scale-invariant; hours just + * keep the series readable). A field with no non-null values yields an empty + * series that degrades to absent — never a fabricated constant. The whole set is + * empty when the user has no environment rows (module off / no home set). + */ +export async function fetchEnvironmentSeries( + userId: string, + since: Date, +): Promise { + const sinceKey = since.toISOString().slice(0, 10); + const rows = await prisma.environmentContext.findMany({ + where: { userId, date: { gte: sinceKey } }, + orderBy: { date: "asc" }, + take: 1000, + select: { + date: true, + tempMean: true, + tempMin: true, + tempMax: true, + apparentMean: true, + sunshineSec: true, + daylightSec: true, + precipSum: true, + pressureMean: true, + pressureDelta: true, + humidityMean: true, + cloudMean: true, + }, + }); + + return ENVIRONMENT_FIELDS.map((field) => { + const points: DailySeriesPoint[] = []; + for (const row of rows) { + const raw = row[field.column]; + if (raw == null || !Number.isFinite(raw)) continue; + // Seconds → hours for the duration fields; pass through otherwise. + const value = + field.column === "sunshineSec" || field.column === "daylightSec" + ? raw / 3600 + : raw; + points.push({ day: row.date, value }); + } + return { key: field.key, role: "behaviour" as const, points }; + }); +} + /** 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 } = wallClockInTz(at, tz); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index f436fd407..279944fd7 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -39,6 +39,10 @@ * link. The series builders live in `correlation-series-builders.ts`. */ import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; +import { + getEnvironmentField, + isEnvironmentChannelKey, +} from "@/lib/environment/fields"; /** Default Benjamini-Hochberg target false-discovery rate. */ export const FDR_Q = 0.1; @@ -102,6 +106,12 @@ export function metricFamily(key: string): string { // tautology as MOOD→MOOD and is excluded too. if (key.startsWith(FACTOR_CHANNEL_PREFIX) || key === "MOOD") return "MOOD"; if (key.startsWith("BLOOD_PRESSURE")) return "BLOOD_PRESSURE"; + // v1.25 (W-ENV) — every environmental-exposure channel (ENV_TEMP_MEAN, + // ENV_DAYLIGHT, …) shares ONE family so the same-family guard never + // lag-correlates two same-day weather fields against each other (a + // near-tautology). They stay free to pair cross-domain against mood / sleep / + // vitals — the whole point of the module. + if (key.startsWith("ENV_")) return "ENVIRONMENT"; // v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form // their OWN single-channel family, so the loop's same-family guard collapses // only the self-lag (compliance→compliance, symptom→symptom). The returned @@ -312,6 +322,11 @@ function humanise(key: string): string { if (key.startsWith("FACTOR:")) { return `rated ${key.slice("FACTOR:".length).replace(/[_-]/g, " ").toLowerCase()}`; } + // v1.25 (W-ENV) — env channels read with their descriptive label ("daily + // temperature", "daylight") rather than the raw "env temp mean" key. + if (isEnvironmentChannelKey(key)) { + return getEnvironmentField(key)?.narrationLabel ?? key.toLowerCase(); + } return key.replace(/_/g, " ").toLowerCase(); } diff --git a/src/lib/jobs/environment-fetch.ts b/src/lib/jobs/environment-fetch.ts new file mode 100644 index 000000000..2f5948bbb --- /dev/null +++ b/src/lib/jobs/environment-fetch.ts @@ -0,0 +1,185 @@ +/** + * v1.25 (W-ENV) — nightly environmental-context fetch. + * + * For every account with the environmental-context module enabled AND a home + * location set, fetch the daily weather/daylight for the recent uncovered days + * and upsert into `EnvironmentContext`. A daily discovery tick (empty payload) + * fans out one per-user job; the per-user job resolves each day's location + * (travel override → home) and fetches a lookback window so the archive feed's + * settling lag (a few days) is absorbed and any day missed across worker + * reboots is re-attempted. The same handler also serves the on-demand backfill + * (an explicit `{ userId, startDate, endDate }` payload from the settings + * surface). + * + * Opt-in + privacy: the discovery filter requires both the module flag and a + * home location, so a disabled account never triggers an outbound fetch. Egress + * runs through `safeFetch` inside the Open-Meteo client. Recurring → pg-boss + * (never a CLI script; the prod image strips `tsx`). + */ +import { prisma } from "@/lib/db"; +import type { PgBoss } from "pg-boss"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { + ENVIRONMENT_LOOKBACK_DAYS, + ENVIRONMENT_MAX_BACKFILL_DAYS, + fetchAndStoreEnvironment, + utcDayKey, +} from "@/lib/environment/service"; +import { recordError } from "@/lib/jobs/worker-status"; +import { workerLog } from "./reminder/shared"; + +export const ENVIRONMENT_FETCH_QUEUE = "environment-fetch"; + +/** Daily 02:10 Europe/Berlin — early, before the 02:30+ maintenance window so + * the night's weather rows exist before any briefing/insight pre-gen reads. */ +export const ENVIRONMENT_FETCH_CRON = "10 2 * * *"; + +export const ENVIRONMENT_FETCH_CONCURRENCY = 1; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Per-user fetch payload. An empty payload (no `userId`) is the daily discovery + * tick. `startDate`/`endDate` (YYYY-MM-DD) drive the on-demand backfill; omitted + * ⇒ the default lookback window ending today. + */ +export interface EnvironmentFetchPayload { + userId?: string; + startDate?: string; + endDate?: string; +} + +/** Resolve the date range for a per-user run (explicit range, else lookback). */ +function resolveRange(payload: EnvironmentFetchPayload): { + startDate: string; + endDate: string; +} { + const today = new Date(); + const endDate = payload.endDate ?? utcDayKey(today); + const startDate = + payload.startDate ?? + utcDayKey( + new Date(today.getTime() - ENVIRONMENT_LOOKBACK_DAYS * MS_PER_DAY), + ); + return { startDate, endDate }; +} + +/** + * Request-side enqueue for a single account: a default-lookback refresh (after + * the user sets/changes their home, so weather appears without waiting for the + * nightly tick) or an explicit-range backfill. No-ops cleanly when no global + * boss is bound (e.g. a web-only deployment) — the nightly cron still covers it. + * The lookback variant is singleton-coalesced per user; an explicit backfill + * range is NOT coalesced so it always runs. + */ +export async function enqueueEnvironmentFetch( + payload: EnvironmentFetchPayload & { userId: string }, +): Promise { + const boss = getGlobalBoss(); + if (!boss) return false; + const options = + payload.startDate || payload.endDate + ? undefined + : { singletonKey: `environment-fetch:${payload.userId}` }; + await boss.send(ENVIRONMENT_FETCH_QUEUE, payload, options); + return true; +} + +/** + * Discovery fan-out: enqueue one per-user fetch job for every account with the + * module on and a home location set. Idempotent (singletonKey-coalesced per + * user). Returns counts for the worker log. + */ +export async function enqueueEnvironmentFetchDiscovery( + boss: PgBoss, +): Promise<{ enqueued: number; skipped: number }> { + // Candidate set: a home location is required for any non-override day, so an + // account without one cannot produce a row. The per-candidate module check + // then enforces the opt-in flag + operator availability. + const candidates = await prisma.user.findMany({ + where: { homeLat: { not: null }, homeLon: { not: null } }, + select: { id: true }, + }); + + let enqueued = 0; + let skipped = 0; + for (const { id } of candidates) { + if (!(await isModuleEnabled(id, "environment"))) { + skipped += 1; + continue; + } + await boss.send( + ENVIRONMENT_FETCH_QUEUE, + { userId: id } satisfies EnvironmentFetchPayload, + { singletonKey: `environment-fetch:${id}` }, + ); + enqueued += 1; + } + return { enqueued, skipped }; +} + +/** + * The queue handler. Empty payload ⇒ discovery fan-out; a `userId` payload ⇒ + * fetch + upsert for that user across the resolved range. Re-checks the module + * gate on the per-user path so a backfill enqueued just before the user + * disabled the module is a no-op. + */ +export async function handleEnvironmentFetch( + boss: PgBoss, + payload: EnvironmentFetchPayload, +): Promise { + if (!payload.userId) { + const { enqueued, skipped } = await enqueueEnvironmentFetchDiscovery(boss); + workerLog( + "info", + `[environment-fetch] discovery enqueued=${enqueued} skipped=${skipped}`, + ); + return; + } + + const userId = payload.userId; + if (!(await isModuleEnabled(userId, "environment"))) { + workerLog( + "info", + `[environment-fetch] user=${userId} module disabled — skipping`, + ); + return; + } + + const { startDate, endDate } = resolveRange(payload); + // Clamp the span so a crafted backfill can never fan out an unbounded range. + const span = enumerateSpanDays(startDate, endDate); + if (span > ENVIRONMENT_MAX_BACKFILL_DAYS) { + workerLog( + "error", + `[environment-fetch] user=${userId} range ${startDate}..${endDate} (${span}d) exceeds cap — skipping`, + ); + return; + } + + try { + const result = await fetchAndStoreEnvironment({ + userId, + startDate, + endDate, + }); + workerLog( + "info", + `[environment-fetch] user=${userId} ${startDate}..${endDate} stored=${result.stored} skipped=${result.skipped} fetches=${result.fetches}`, + ); + } catch (err) { + recordError(); + workerLog("error", `[environment-fetch] user=${userId} failed`, err); + throw err; + } +} + +function enumerateSpanDays(start: string, end: string): number { + const [sy, sm, sd] = start.split("-").map(Number); + const [ey, em, ed] = end.split("-").map(Number); + const days = Math.round( + (Date.UTC(ey, em - 1, ed) - Date.UTC(sy, sm - 1, sd)) / MS_PER_DAY, + ); + return days + 1; +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 69ce90674..a6d355f6a 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -75,6 +75,13 @@ import { handleEncryptionKeyRotate, type EncryptionKeyRotatePayload, } from "@/lib/jobs/encryption-key-rotate"; +import { + ENVIRONMENT_FETCH_QUEUE, + ENVIRONMENT_FETCH_CRON, + ENVIRONMENT_FETCH_CONCURRENCY, + handleEnvironmentFetch, + type EnvironmentFetchPayload, +} from "@/lib/jobs/environment-fetch"; import { recordError } from "@/lib/jobs/worker-status"; import { workerLog, BOOT_BACKFILL_STAGGER_SECONDS } from "./shared"; import { createAndSchedule, type ScheduleEntry } from "./registrar-shared"; @@ -216,6 +223,12 @@ const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *"; const MED_NOTES_ENCRYPTION_BACKFILL_CRON = "5 4 * * *"; const allQueues = [ + // v1.25 (W-ENV) — nightly environmental-context fetch. A daily discovery tick + // fans out one per-user job per opted-in account with a home location; the + // queue also serves the on-demand backfill from the Environment settings + // surface. Without this entry pg-boss never provisions it and both the cron + // and the backfill button silently no-op. + ENVIRONMENT_FETCH_QUEUE, DATA_BACKUP_QUEUE, RATE_LIMIT_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_QUEUE, @@ -277,6 +290,9 @@ const allQueues = [ ]; const schedules: ScheduleEntry[] = [ + // v1.25 (W-ENV) — daily 02:10 Europe/Berlin discovery tick (empty payload) + // that fans out one per-user environment fetch per opted-in account. + [ENVIRONMENT_FETCH_QUEUE, ENVIRONMENT_FETCH_CRON], [DATA_BACKUP_QUEUE, DATA_BACKUP_CRON], [RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON], [IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON], @@ -601,6 +617,20 @@ export async function registerMaintenanceQueues( }, ); + // v1.25 (W-ENV) — nightly environment fetch. The daily discovery tick (empty + // payload) fans out one per-user job per opted-in account; the queue also + // serves the on-demand backfill payloads from the settings surface. Serial + // concurrency so the staggered outbound fetches never crowd the request pool. + await boss.work( + ENVIRONMENT_FETCH_QUEUE, + { localConcurrency: ENVIRONMENT_FETCH_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + await handleEnvironmentFetch(boss, job.data); + } + }, + ); + return allQueues; } diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 5b76f000c..01b3de88b 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -352,10 +352,15 @@ describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => { }); describe("registry — opt-in marker", () => { - it("marks only the mcp module opt-in (every other module is default-on)", () => { + it("marks the mcp + environment modules opt-in (every other module is default-on)", () => { expect(isOptInModule("mcp")).toBe(true); + // v1.25 (W-ENV) — the environmental-context module is opt-in too: it + // performs an outbound weather fetch tied to a coarse location, so it ships + // dark like the remote MCP endpoint. + expect(isOptInModule("environment")).toBe(true); + const optIn = new Set(["mcp", "environment"]); for (const key of MODULE_KEYS) { - if (key === "mcp") continue; + if (optIn.has(key)) continue; expect(isOptInModule(key)).toBe(false); } }); diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 0ae94722a..e46a067a8 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -115,6 +115,12 @@ export const MODULE_KEYS = [ // re-enabling finds the rows intact. "medications", "doctorReport", + // v1.25.0 (W-ENV) — environmental-context module. Like `mcp`, it is OPT-IN + // (off by default): it performs an outbound weather fetch tied to where the + // user physically is (a coarse home / travel location), so opt-in is the + // right privacy default. With it off no job runs, no row is written, and no + // egress happens. The `optIn` marker inverts the per-user default. + "environment", // v1.22.0 — the remote Model Context Protocol endpoint (`/mcp`). Unlike // every other module this is OPT-IN (off by default): it exposes a new // external-assistant attack surface, so it ships dark and the operator / @@ -256,6 +262,17 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.doctorReport.description", category: "export", }, + environment: { + key: "environment", + labelKey: "modules.environment.label", + descriptionKey: "modules.environment.description", + category: "device", + // Off by default: performs an outbound weather fetch tied to a coarse + // location, so it ships dark and the user turns it on deliberately. The + // home location + travel overrides + backfill live on the dedicated + // Environment settings surface (rendered when the module is on). + optIn: true, + }, mcp: { key: "mcp", labelKey: "modules.mcp.label", diff --git a/src/lib/query-keys/environment.ts b/src/lib/query-keys/environment.ts new file mode 100644 index 000000000..25a85c336 --- /dev/null +++ b/src/lib/query-keys/environment.ts @@ -0,0 +1,16 @@ +/** + * Query keys — environmental-context module (v1.25, W-ENV). The overview + * (home + travel overrides + stored-day summary) and the geocoding search. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const environmentKeys = { + /** + * `environment()` is the root prefix every environment write (set home, add / + * remove a travel override, trigger a backfill) invalidates through, so the + * settings surface repaints in lockstep. + */ + environment: () => ["environment"] as const, + /** Geocoding search results for a query string (cached per query). */ + environmentGeocode: (query: string) => + ["environment", "geocode", query] as const, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 8b40d9320..0213c3d57 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -21,6 +21,7 @@ import { coachKeys } from "./coach"; import { familyHistoryKeys } from "./family-history"; import { cycleKeys } from "./cycle"; import { dashboardKeys } from "./dashboard"; +import { environmentKeys } from "./environment"; import { illnessKeys } from "./illness"; import { insightsKeys } from "./insights"; import { integrationKeys } from "./integrations"; @@ -52,6 +53,7 @@ export const queryKeys = { ...mentalHealthKeys, ...allergyKeys, ...familyHistoryKeys, + ...environmentKeys, }; /** diff --git a/src/lib/signals/__tests__/registry-invariants.test.ts b/src/lib/signals/__tests__/registry-invariants.test.ts index d1538ba12..337b17f27 100644 --- a/src/lib/signals/__tests__/registry-invariants.test.ts +++ b/src/lib/signals/__tests__/registry-invariants.test.ts @@ -40,6 +40,8 @@ describe("signal registry — structural sanity", () => { for (const signal of allSignals()) { if (signal.kind === "biomarker") { expect(signal.source.biomarkerKey.length).toBeGreaterThan(0); + } else if (signal.kind === "environment") { + expect(signal.source.environmentChannelKey.length).toBeGreaterThan(0); } else { expect(signal.source.measurementType.length).toBeGreaterThan(0); } @@ -57,7 +59,7 @@ describe("signal registry — structural sanity", () => { it("signalForMeasurementType round-trips every measurement/score signal", () => { for (const signal of allSignals()) { - if (signal.kind === "biomarker") continue; + if (signal.kind !== "measurement" && signal.kind !== "score") continue; expect(signalForMeasurementType(signal.source.measurementType)).toBe( signal, ); diff --git a/src/lib/signals/adapters/correlation.ts b/src/lib/signals/adapters/correlation.ts index 777399d0a..a78cc582a 100644 --- a/src/lib/signals/adapters/correlation.ts +++ b/src/lib/signals/adapters/correlation.ts @@ -13,7 +13,10 @@ import { allSignals } from "@/lib/signals/registry"; export function deriveBucketedTypes(): MeasurementType[] { const types: MeasurementType[] = []; for (const signal of allSignals()) { - if (signal.kind === "biomarker") continue; + // biomarker (labs path) + environment (W-ENV exposure channels) are not + // `MeasurementType`-backed — env feeds the engine through its own series + // builder, not the measurement-keyed bucketed types. + if (signal.kind === "biomarker" || signal.kind === "environment") continue; if (!signal.surfaces.correlationEligible) continue; types.push(signal.source.measurementType); } diff --git a/src/lib/signals/adapters/fhir.ts b/src/lib/signals/adapters/fhir.ts index 1daafb530..d3dc44ffd 100644 --- a/src/lib/signals/adapters/fhir.ts +++ b/src/lib/signals/adapters/fhir.ts @@ -13,7 +13,9 @@ import { allSignals } from "@/lib/signals/registry"; export function deriveMeasurementLoinc(): Record { const entries: Array<[string, LoincMapping]> = []; for (const signal of allSignals()) { - if (signal.kind === "biomarker") continue; + // Only `MeasurementType`-backed signals key `MEASUREMENT_LOINC`. biomarker + // (labs) + environment (W-ENV) signals are never Measurement rows. + if (signal.kind === "biomarker" || signal.kind === "environment") continue; if (!signal.fhir) continue; entries.push([ signal.source.measurementType, diff --git a/src/lib/signals/adapters/metric-status.ts b/src/lib/signals/adapters/metric-status.ts index 2470a6733..ec0d18522 100644 --- a/src/lib/signals/adapters/metric-status.ts +++ b/src/lib/signals/adapters/metric-status.ts @@ -15,7 +15,7 @@ import { getSignal } from "@/lib/signals/registry"; /** Build the `MetricStatusMeta` for one registered metric-status id. */ function metaFor(id: MetricStatusMetricId): MetricStatusMeta { const signal = getSignal(id); - if (!signal || signal.kind === "biomarker") { + if (!signal || signal.kind === "biomarker" || signal.kind === "environment") { throw new Error(`signal registry: no measurement signal for "${id}"`); } // Build field-by-field, omitting the optional keys when absent so the diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index 70d472f4f..3c51b5049 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -30,8 +30,15 @@ import type { import type { FhirObservationCategory } from "@/lib/fhir/loinc-map"; import { FEVER_BAND_C } from "@/lib/clinical-floors"; -/** Discriminator: a device/manual reading, a lab biomarker, or a computed score. */ -export type SignalKind = "measurement" | "biomarker" | "score"; +/** + * Discriminator: a device/manual reading, a lab biomarker, a computed score, or + * an environmental exposure. `environment` (v1.25 W-ENV) is NOT backed by a + * `MeasurementType` — its daily value lives in `EnvironmentContext` and feeds + * the correlation engine through the env channel series builder, not the + * measurement-keyed `BUCKETED_TYPES`. It registers here so the env signals get + * a stable key + display metadata in the one cross-surface table. + */ +export type SignalKind = "measurement" | "biomarker" | "score" | "environment"; /** Reuse the assessment registry's direction vocabulary verbatim. */ export type SignalDirection = MetricDirection; @@ -114,6 +121,17 @@ export interface BiomarkerSignalSource { biomarkerKey: string; } +/** + * How an `environment` signal is sourced (v1.25 W-ENV). The value is a daily + * `EnvironmentContext` column, surfaced to the correlation engine under + * `environmentChannelKey` (the `ENV_*` channel key from + * `@/lib/environment/fields`). No `MeasurementType` — env data is never a + * Measurement row. + */ +export interface EnvironmentSignalSource { + environmentChannelKey: string; +} + /** * The canonical cross-surface signal definition, discriminated on `kind`. * `measurement` and `score` both resolve to a `MeasurementType`; `biomarker` @@ -122,7 +140,8 @@ export interface BiomarkerSignalSource { export type SignalDefinition = | (SignalCommon & { kind: "measurement"; source: MeasurementSignalSource }) | (SignalCommon & { kind: "score"; source: MeasurementSignalSource }) - | (SignalCommon & { kind: "biomarker"; source: BiomarkerSignalSource }); + | (SignalCommon & { kind: "biomarker"; source: BiomarkerSignalSource }) + | (SignalCommon & { kind: "environment"; source: EnvironmentSignalSource }); /** Convenience: build a Coach-snapshot facet from a scope token. */ function coachScope(scope: string): { scope: string } { @@ -1504,6 +1523,128 @@ export const SIGNALS: Record = { mcp: false, }, }, + // ── v1.25 (W-ENV) environmental-context module — exposure signals ───────── + // Daily weather/daylight, fetched server-side from Open-Meteo for the user's + // coarse home (or a travel override) and stored in `EnvironmentContext`. NOT + // `MeasurementType`-backed: `correlationEligible:true` declares intent, but + // the wiring runs through the env channel series builder, not the + // measurement-keyed `BUCKETED_TYPES` (the correlation adapter skips + // `kind:"environment"`). Off the Coach snapshot + MCP surfaces in v1 + // (`coachSnapshot:false ⇒ mcp:false`); no FHIR facet (env observations are + // not Measurement rows, so they never enter `MEASUREMENT_LOINC`). Direction is + // `target-band` — a neutral exposure, not a "better/worse" vital. + ENV_TEMP_MEAN: { + key: "ENV_TEMP_MEAN", + kind: "environment", + source: { environmentChannelKey: "ENV_TEMP_MEAN" }, + i18nPrefix: "environment.fields.tempMean", + displayName: "Daily temperature", + unit: "°C", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_TEMP_MIN: { + key: "ENV_TEMP_MIN", + kind: "environment", + source: { environmentChannelKey: "ENV_TEMP_MIN" }, + i18nPrefix: "environment.fields.tempMin", + displayName: "Overnight low temperature", + unit: "°C", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_SUNSHINE: { + key: "ENV_SUNSHINE", + kind: "environment", + source: { environmentChannelKey: "ENV_SUNSHINE" }, + i18nPrefix: "environment.fields.sunshine", + displayName: "Sunshine duration", + unit: "h", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_DAYLIGHT: { + key: "ENV_DAYLIGHT", + kind: "environment", + source: { environmentChannelKey: "ENV_DAYLIGHT" }, + i18nPrefix: "environment.fields.daylight", + displayName: "Daylight length", + unit: "h", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_PRECIP: { + key: "ENV_PRECIP", + kind: "environment", + source: { environmentChannelKey: "ENV_PRECIP" }, + i18nPrefix: "environment.fields.precip", + displayName: "Precipitation", + unit: "mm", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_PRESSURE_MEAN: { + key: "ENV_PRESSURE_MEAN", + kind: "environment", + source: { environmentChannelKey: "ENV_PRESSURE_MEAN" }, + i18nPrefix: "environment.fields.pressureMean", + displayName: "Barometric pressure", + unit: "hPa", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_PRESSURE_DELTA: { + key: "ENV_PRESSURE_DELTA", + kind: "environment", + source: { environmentChannelKey: "ENV_PRESSURE_DELTA" }, + i18nPrefix: "environment.fields.pressureDelta", + displayName: "Pressure swing", + unit: "hPa", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, }; /** Type guard: narrow an arbitrary string to a registered signal key. */ diff --git a/src/lib/validations/environment.ts b/src/lib/validations/environment.ts new file mode 100644 index 000000000..ee2c65ee3 --- /dev/null +++ b/src/lib/validations/environment.ts @@ -0,0 +1,70 @@ +/** + * v1.25 (W-ENV) — environmental-context request validation. + * + * The home location, the travel overrides, and the backfill span. Coordinates + * are bounded to valid lat/lon; the labels are length-capped plaintext (a city + * name, never a quasi-identifier). Dates are strict YYYY-MM-DD. The shared + * shapes are reused by the OpenAPI registry so the wire contract stays + * single-source. + */ +import { z } from "zod/v4"; + +/** Strict YYYY-MM-DD calendar day. */ +export const dayStringSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Expected a YYYY-MM-DD date"); + +const latSchema = z.number().gte(-90).lte(90); +const lonSchema = z.number().gte(-180).lte(180); +const labelSchema = z.string().trim().min(1).max(200); + +/** A coarse home location set in settings (city-level granularity). */ +export const homeLocationSchema = z + .object({ + lat: latSchema, + lon: lonSchema, + label: labelSchema, + /** IANA timezone (anchors the stored day-key). */ + timezone: z.string().trim().min(1).max(64), + }) + .strict(); + +export type HomeLocationInput = z.infer; + +/** A manual travel override covering an inclusive date range. */ +export const travelLocationSchema = z + .object({ + startDate: dayStringSchema, + endDate: dayStringSchema, + lat: latSchema, + lon: lonSchema, + label: labelSchema, + }) + .strict() + .refine((v) => v.startDate <= v.endDate, { + message: "startDate must be on or before endDate", + path: ["endDate"], + }); + +export type TravelLocationInput = z.infer; + +/** A backfill request: fetch + store the environment rows over a date range. */ +export const environmentBackfillSchema = z + .object({ + startDate: dayStringSchema, + endDate: dayStringSchema, + }) + .strict() + .refine((v) => v.startDate <= v.endDate, { + message: "startDate must be on or before endDate", + path: ["endDate"], + }); + +export type EnvironmentBackfillInput = z.infer< + typeof environmentBackfillSchema +>; + +/** Geocoding search query (`?q=`). */ +export const geocodeQuerySchema = z + .object({ q: z.string().trim().min(1).max(120) }) + .strict(); diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 574237115..4af378c4b 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -317,6 +317,7 @@ describe("doctor-report — module enable/disable gating", () => { insights: true, medications: true, doctorReport: true, + environment: true, mcp: true, } as const;