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 15d669cd8..0858a5ac4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9737,6 +9737,8 @@ components: type: boolean doctorReport: type: boolean + environment: + type: boolean mcp: type: boolean additionalProperties: false @@ -16775,6 +16777,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. @@ -16792,6 +16797,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 d1b80c95b..31b89c1c3 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 6b637b7f9..1752b79c5 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 511245644..535d6e05e 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 722bb3c26..b0802ced0 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 8ae1aef9a..86b54b3b0 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 382247918..134c0ca96 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/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/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/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/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/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;