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);
});