mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
cdee3e3efc
commit
088cad6c64
30 files changed
+1537
-2
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);
|
||||
});
|
||||
Reference in new issue
Block a user