mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge feat/v125-environmental into release/v1.25.0
Environmental context module (migration 0217).
This commit is contained in:
commit
a1b4f0d00e
45 files changed
+2725
-64
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -20,6 +20,10 @@ vi.mock("@/lib/db", () => ({
|
||||
illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.22 — labs ↔ outcome pass fetches lab draws; default empty.
|
||||
labResult: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.25 (W-ENV) — the route folds the environmental-exposure behaviour
|
||||
// channels in (read from EnvironmentContext); default empty so no env
|
||||
// channel produces a point.
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -113,6 +117,9 @@ beforeEach(() => {
|
||||
);
|
||||
// v1.22 — labs ↔ outcome pass reads lab draws; default empty.
|
||||
(prisma.labResult.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(
|
||||
prisma.environmentContext.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import {
|
||||
fetchComplianceSeries,
|
||||
fetchEnvironmentSeries,
|
||||
fetchLabDraws,
|
||||
fetchSymptomSeries,
|
||||
} from "@/lib/insights/correlation-channel-series";
|
||||
@@ -152,11 +153,16 @@ export const GET = apiHandler(async () => {
|
||||
// their own sources. Each degrades to an empty series when the user has no
|
||||
// data, so the discovery loop drops the channel (it cannot clear n ≥ 20).
|
||||
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside.
|
||||
const [complianceSeries, symptomSeries, labDraws] = await Promise.all([
|
||||
fetchComplianceSeries(user.id, tz, since),
|
||||
fetchSymptomSeries(user.id, tz, since),
|
||||
fetchLabDraws(user.id, tz, since),
|
||||
]);
|
||||
const [complianceSeries, symptomSeries, labDraws, environmentSeries] =
|
||||
await Promise.all([
|
||||
fetchComplianceSeries(user.id, tz, since),
|
||||
fetchSymptomSeries(user.id, tz, since),
|
||||
fetchLabDraws(user.id, tz, since),
|
||||
// v1.25 (W-ENV) — environmental-exposure behaviour channels (weather /
|
||||
// daylight). Empty when the module is off / no home set, so the channels
|
||||
// degrade to absent. The module gate is implicit: no rows ⇒ no channels.
|
||||
fetchEnvironmentSeries(user.id, since),
|
||||
]);
|
||||
|
||||
const points = (key: string): DailySeriesPoint[] =>
|
||||
key === "MOOD"
|
||||
@@ -180,6 +186,12 @@ export const GET = apiHandler(async () => {
|
||||
series.push({ key, role: "outcome", points: points(key) });
|
||||
}
|
||||
}
|
||||
// v1.25 (W-ENV) — fold the environmental-exposure behaviour channels in. They
|
||||
// pair (lag D → D+1) against every outcome above; the n ≥ 20 / FDR / effect-
|
||||
// size gates apply unchanged, so a thin weather series degrades to absent.
|
||||
for (const envSeries of environmentSeries) {
|
||||
series.push(envSeries);
|
||||
}
|
||||
|
||||
const result = discoverCorrelations(series);
|
||||
|
||||
@@ -213,6 +225,13 @@ export const GET = apiHandler(async () => {
|
||||
emerging_window_days: emerging.windowDays,
|
||||
lab_draws: labDraws.length,
|
||||
lab_correlations: labCorrelations.discovered.length,
|
||||
// v1.25 (W-ENV) — env channel reach (sum of stored daily points across
|
||||
// the exposure channels) so a dashboard can see whether weather was
|
||||
// available for the scan.
|
||||
environment_days: environmentSeries.reduce(
|
||||
(sum, s) => sum + s.points.length,
|
||||
0,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user