feat(environment): add opt-in module, settings surface, API, and nightly fetch

Add the opt-in Environment module (default off), its Settings surface (home
location search, travel overrides, backfill, with Open-Meteo attribution), the
gated API routes (overview, home, geocode, travel CRUD, backfill), the nightly
pg-boss fetch job with a lookback window plus on-demand backfill, the query
keys, six-locale strings, and the regenerated OpenAPI. The module performs an
outbound weather fetch tied to a coarse location, so it ships dark and the user
turns it on deliberately; off means no job, no row, no egress.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:58:52 +02:00
1 parent cdee3e3efc
commit 088cad6c64
30 files changed
+1537 -2

No files matched your search

+89
View File
@@ -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);
});
+50
View File
@@ -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 });
});
+67
View File
@@ -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,
},
});
});
+84
View File
@@ -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,
});
});
@@ -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 });
},
);
+79
View File
@@ -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);
});
+2
View File
@@ -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,
@@ -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",
@@ -0,0 +1,440 @@
"use client";
/**
* `<EnvironmentSection>` — 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<EnvironmentOverview>("/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<GeocodeResult[]>([]);
const [travelQuery, setTravelQuery] = useState("");
const [travelResults, setTravelResults] = useState<GeocodeResult[]>([]);
const [travelLoc, setTravelLoc] = useState<GeocodeResult | null>(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 (
<SettingsCard>
<SettingsCardHeader
icon={CloudSun}
title={t("settings.sections.environment.title")}
className="mb-2"
/>
<p className="text-muted-foreground pl-7 text-sm leading-relaxed">
{t("settings.sections.environment.disabledHint")}
</p>
</SettingsCard>
);
}
const data = overview.data;
const home = data?.home ?? null;
return (
<div className="space-y-6">
{/* Home location */}
<SettingsCard>
<SettingsCardHeader
icon={MapPin}
title={t("settings.sections.environment.home.title")}
className="mb-2"
/>
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
{t("settings.sections.environment.home.description")}
</p>
<div className="space-y-3 pl-7">
{home ? (
<p className="text-sm" data-slot="environment-home-current">
{t("settings.sections.environment.home.current", {
label: home.label ?? `${home.lat}, ${home.lon}`,
})}
</p>
) : (
<p className="text-muted-foreground text-sm">
{t("settings.sections.environment.home.none")}
</p>
)}
<div className="flex gap-2">
<Input
value={homeQuery}
onChange={(e) => setHomeQuery(e.target.value)}
placeholder={t("settings.sections.environment.searchPlaceholder")}
aria-label={t("settings.sections.environment.searchPlaceholder")}
/>
<Button
type="button"
variant="secondary"
disabled={homeQuery.trim().length === 0 || searchHome.isPending}
onClick={() => searchHome.mutate()}
>
{t("settings.sections.environment.search")}
</Button>
</div>
{homeResults.length > 0 && (
<ul className="divide-border divide-y rounded-md border">
{homeResults.map((r) => (
<li
key={`${r.lat},${r.lon},${r.label}`}
className="flex items-center justify-between gap-2 p-2"
>
<span className="text-sm">{r.label}</span>
<Button
type="button"
size="sm"
disabled={setHome.isPending}
onClick={() => setHome.mutate(r)}
>
{t("settings.sections.environment.use")}
</Button>
</li>
))}
</ul>
)}
</div>
</SettingsCard>
{/* Travel overrides */}
<SettingsCard>
<SettingsCardHeader
icon={Plane}
title={t("settings.sections.environment.travel.title")}
className="mb-2"
/>
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
{t("settings.sections.environment.travel.description")}
</p>
<div className="space-y-3 pl-7">
{data && data.travel.length > 0 ? (
<ul className="divide-border divide-y rounded-md border">
{data.travel.map((tr) => (
<li
key={tr.id}
className="flex items-center justify-between gap-2 p-2"
>
<span className="text-sm">
{tr.label} · {tr.startDate} – {tr.endDate}
</span>
<Button
type="button"
size="sm"
variant="ghost"
aria-label={t(
"settings.sections.environment.travel.remove",
)}
disabled={removeTravel.isPending}
onClick={() => removeTravel.mutate(tr.id)}
>
<Trash2 className="size-4" />
</Button>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground text-sm">
{t("settings.sections.environment.travel.none")}
</p>
)}
<div className="flex gap-2">
<Input
value={travelQuery}
onChange={(e) => setTravelQuery(e.target.value)}
placeholder={t("settings.sections.environment.searchPlaceholder")}
aria-label={t("settings.sections.environment.searchPlaceholder")}
/>
<Button
type="button"
variant="secondary"
disabled={
travelQuery.trim().length === 0 || searchTravel.isPending
}
onClick={() => searchTravel.mutate()}
>
{t("settings.sections.environment.search")}
</Button>
</div>
{travelResults.length > 0 && (
<ul className="divide-border divide-y rounded-md border">
{travelResults.map((r) => (
<li
key={`${r.lat},${r.lon},${r.label}`}
className="flex items-center justify-between gap-2 p-2"
>
<span className="text-sm">{r.label}</span>
<Button
type="button"
size="sm"
variant={
travelLoc?.label === r.label ? "default" : "secondary"
}
onClick={() => setTravelLoc(r)}
>
{t("settings.sections.environment.select")}
</Button>
</li>
))}
</ul>
)}
{travelLoc && (
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label htmlFor="env-travel-start">
{t("settings.sections.environment.startDate")}
</Label>
<Input
id="env-travel-start"
type="date"
value={travelStart}
onChange={(e) => setTravelStart(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="env-travel-end">
{t("settings.sections.environment.endDate")}
</Label>
<Input
id="env-travel-end"
type="date"
value={travelEnd}
onChange={(e) => setTravelEnd(e.target.value)}
/>
</div>
<Button
type="button"
disabled={travelStart > travelEnd || addTravel.isPending}
onClick={() => addTravel.mutate()}
>
{t("settings.sections.environment.travel.add", {
label: travelLoc.label,
})}
</Button>
</div>
)}
</div>
</SettingsCard>
{/* Backfill */}
<SettingsCard>
<SettingsCardHeader
icon={CloudSun}
title={t("settings.sections.environment.backfill.title")}
className="mb-2"
/>
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
{t("settings.sections.environment.backfill.description")}
</p>
<div className="flex flex-wrap items-end gap-2 pl-7">
<div className="space-y-1">
<Label htmlFor="env-backfill-start">
{t("settings.sections.environment.startDate")}
</Label>
<Input
id="env-backfill-start"
type="date"
value={backfillStart}
onChange={(e) => setBackfillStart(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="env-backfill-end">
{t("settings.sections.environment.endDate")}
</Label>
<Input
id="env-backfill-end"
type="date"
value={backfillEnd}
onChange={(e) => setBackfillEnd(e.target.value)}
/>
</div>
<Button
type="button"
disabled={
!home ||
backfillStart.length === 0 ||
backfillStart > backfillEnd ||
backfill.isPending
}
onClick={() => backfill.mutate()}
>
{t("settings.sections.environment.backfill.run")}
</Button>
</div>
{data && (
<p className="text-muted-foreground mt-3 pl-7 text-xs">
{t("settings.sections.environment.storedDays", {
count: data.context.days,
})}
</p>
)}
</SettingsCard>
{/* Attribution (CC BY 4.0, required) */}
<p className="text-muted-foreground pl-1 text-xs">
{data?.attribution ?? "Weather data by Open-Meteo.com"}
</p>
</div>
);
}
@@ -30,6 +30,7 @@ import {
Blocks,
CalendarHeart,
Droplet,
CloudSun,
Dumbbell,
FileText,
MessageCircleHeart,
@@ -72,6 +73,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
// 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,
};
@@ -16,6 +16,7 @@ import {
Blocks,
Bot,
CalendarCheck,
CloudSun,
Download,
ClipboardList,
FileHeart,
@@ -60,6 +61,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
mood: Smile,
labs: FlaskConical,
illness: Thermometer,
environment: CloudSun,
anamnesis: ClipboardList,
vorsorge: CalendarCheck,
thresholds: SlidersHorizontal,
+4
View File
@@ -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.
@@ -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
@@ -83,6 +83,7 @@ function moduleMap(
insights: true,
medications: true,
doctorReport: true,
environment: true,
mcp: true,
};
return { ...base, ...overrides };
+185
View File
@@ -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<boolean> {
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<void> {
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;
}
@@ -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<EnvironmentFetchPayload>(
ENVIRONMENT_FETCH_QUEUE,
{ localConcurrency: ENVIRONMENT_FETCH_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
await handleEnvironmentFetch(boss, job.data);
}
},
);
return allQueues;
}
+7 -2
View File
@@ -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);
}
});
+17
View File
@@ -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<Record<ModuleKey, ModuleDefinition>> =
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",
+16
View File
@@ -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,
};
+2
View File
@@ -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,
};
/**
+70
View File
@@ -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<typeof homeLocationSchema>;
/** 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<typeof travelLocationSchema>;
/** 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();