Merge environment data into release/v1.42.0

# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 10:04:45 +02:00
commit 7bcb6f71d9
73 files changed
+6061 -385

No files matched your search

+4 -2
View File
@@ -55,9 +55,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const profile = await prisma.user.findUnique({
where: { id: user.id },
select: { homeLat: true, homeSince: true },
select: { homeLat: true, homeLocationEncrypted: true, homeSince: true },
});
if (profile?.homeLat == null) {
// The sealed home (v1.42), or the readable one the encryption backfill has
// not cleared yet.
if (profile?.homeLocationEncrypted == null && profile?.homeLat == null) {
return apiError("Set a home location before backfilling.", 409, {
errorCode: "environment.no_home",
});
+19 -10
View File
@@ -16,6 +16,7 @@ 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 { sealLocation } from "@/lib/environment/location-cipher";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
export const PUT = apiHandler(async (request: NextRequest) => {
@@ -46,14 +47,22 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// set/update re-stamps — the home is "effective from the moment it is set".
const homeSince = new Date();
const home = {
homeLat: roundCoarse(lat),
homeLon: roundCoarse(lon),
homeLabel: label,
homeTimezone: timezone,
homeSince,
lat: roundCoarse(lat),
lon: roundCoarse(lon),
label,
};
await prisma.user.update({ where: { id: user.id }, data: home });
await prisma.user.update({
where: { id: user.id },
data: {
homeLocationEncrypted: sealLocation(home),
homeLat: null,
homeLon: null,
homeLabel: null,
homeTimezone: timezone,
homeSince,
},
});
// Kick a lookback refresh so recent days populate promptly. No-ops cleanly
// when no worker is bound; the nightly cron still covers it.
@@ -63,10 +72,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return apiSuccess({
home: {
lat: home.homeLat,
lon: home.homeLon,
label: home.homeLabel,
timezone: home.homeTimezone,
lat: home.lat,
lon: home.lon,
label: home.label,
timezone,
since: homeSince.toISOString(),
},
});
+58 -7
View File
@@ -1,17 +1,68 @@
/**
* `PATCH /api/environment/preferences` — the caller's environment switches,
* today `airQualityEnabled` (v1.42, #615). Module-gated like the rest of the
* environment surface.
* environment surface; `userId` is narrowed from auth.
*
* Contract stub: authenticates as the finished route will, then answers 501.
* Off: the nightly run stops asking the air-quality feed for this account,
* and the overview, the Coach, MCP and the correlations leave the air-quality
* values out. Days already stored keep theirs. On again: a lookback run is
* queued, and the nightly gap fill catches up on the days that never had any.
* The operator switch (`ENVIRONMENT_AIR_QUALITY_DISABLED`) wins either way,
* and the answer says when it does.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NextRequest } from "next/server";
export const PATCH = apiHandler(async () => {
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 { environmentPreferencesSchema } from "@/lib/validations/environment";
import { isAirQualityOperatorDisabled } from "@/lib/environment/open-meteo-air-quality";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
return notImplemented();
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = environmentPreferencesSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "environment.invalid",
});
}
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { environmentAirQualityEnabled: true },
});
const airQualityEnabled = parsed.data.airQualityEnabled;
await prisma.user.update({
where: { id: user.id },
data: { environmentAirQualityEnabled: airQualityEnabled },
});
const operatorDisabled = isAirQualityOperatorDisabled();
// Turned on: fetch the recent days now rather than at night. No-ops when
// no worker is bound; the nightly run covers it.
if (
airQualityEnabled &&
!operatorDisabled &&
before?.environmentAirQualityEnabled === false
) {
await enqueueEnvironmentFetch({ userId: user.id });
}
annotate({
action: { name: "environment.preferences.update" },
meta: { air_quality_enabled: airQualityEnabled },
});
return apiSuccess({ airQualityEnabled, operatorDisabled });
});
+174 -41
View File
@@ -5,6 +5,13 @@
* 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.
*
* v1.42 (#615) — the locations are stored sealed and opened here, so the
* answer keeps its readable shape. Added beside it: the air-quality state
* (the account switch, the operator switch, how many days carry air-quality
* values), the newest stored day for the dashboard chips (with its air
* quality only while the part is on), and every attribution line in
* `attributions`.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -12,6 +19,10 @@ 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";
import { isAirQualityOperatorDisabled } from "@/lib/environment/open-meteo-air-quality";
import { environmentAttributionLines } from "@/lib/environment/air-quality-contract";
import { readLocation } from "@/lib/environment/location-cipher";
import { isAirQualityActive } from "@/lib/environment/service";
import { ENVIRONMENT_FETCH_QUEUE } from "@/lib/jobs/environment-fetch";
import { readQueueFailureForUser } from "@/lib/jobs/job-failures";
@@ -23,50 +34,100 @@ export const GET = apiHandler(async () => {
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
const [profile, travel, contextCount, latest, lastFetchFailure] =
await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
homeLat: true,
homeLon: true,
homeLabel: true,
homeTimezone: true,
homeSince: 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 },
}),
// A day count on its own cannot tell an empty module from a broken one —
// which is exactly how a job that failed on every run since the module
// shipped could render as "0 days recorded" and nothing else. The queue
// knows; the overview asks it.
readQueueFailureForUser(ENVIRONMENT_FETCH_QUEUE, user.id),
]);
const [
profile,
travelRows,
contextCount,
latest,
airDays,
latestAir,
lastFetchFailure,
] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
homeLat: true,
homeLon: true,
homeLabel: true,
homeLocationEncrypted: true,
homeTimezone: true,
homeSince: true,
timezone: true,
environmentAirQualityEnabled: true,
},
}),
prisma.environmentTravelLocation.findMany({
where: { userId: user.id },
orderBy: { startDate: "desc" },
select: {
id: true,
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
locationEncrypted: true,
},
}),
prisma.environmentContext.count({ where: { userId: user.id } }),
prisma.environmentContext.findFirst({
where: { userId: user.id },
orderBy: { date: "desc" },
select: {
date: true,
fetchedAt: true,
tempMin: true,
tempMax: true,
apparentMax: true,
pm25Mean: true,
pm10Mean: true,
no2Mean: true,
o3Max8h: true,
eaqiMax: true,
uvIndexMax: true,
dustMax: true,
pollenAlderMax: true,
pollenBirchMax: true,
pollenGrassMax: true,
pollenMugwortMax: true,
pollenOliveMax: true,
pollenRagweedMax: true,
aqFetchedAt: true,
},
}),
// A day "carries air quality" when the feed returned hourly values for
// it; a day fetched with nothing in it (outside the feed) does not count.
prisma.environmentContext.count({
where: { userId: user.id, aqHours: { gt: 0 } },
}),
prisma.environmentContext.findFirst({
where: { userId: user.id, aqHours: { gt: 0 } },
orderBy: { date: "desc" },
select: { date: true, aqDomain: true },
}),
// A day count on its own cannot tell an empty module from a broken one —
// which is exactly how a job that failed on every run since the module
// shipped could render as "0 days recorded" and nothing else. The queue
// knows; the overview asks it.
readQueueFailureForUser(ENVIRONMENT_FETCH_QUEUE, user.id),
]);
const homeLocation = profile
? readLocation({
sealed: profile.homeLocationEncrypted,
lat: profile.homeLat,
lon: profile.homeLon,
// The label may be absent on a pre-v1.42 home; the answer has
// always allowed a null label there.
label: profile.homeLabel ?? "",
})
: null;
const home =
profile?.homeLat != null && profile?.homeLon != null
profile && homeLocation
? {
lat: profile.homeLat,
lon: profile.homeLon,
label: profile.homeLabel,
lat: homeLocation.lat,
lon: homeLocation.lon,
label: homeLocation.label === "" ? null : homeLocation.label,
timezone: profile.homeTimezone ?? profile.timezone,
// Effective-from instant: the settings surface uses it to label the
// home and to prefill the backfill start (conservative default range).
@@ -74,12 +135,72 @@ export const GET = apiHandler(async () => {
}
: null;
const travel = travelRows.flatMap((row) => {
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.label,
});
return location
? [
{
id: row.id,
startDate: row.startDate,
endDate: row.endDate,
...location,
},
]
: [];
});
const accountEnabled = profile?.environmentAirQualityEnabled ?? true;
const operatorDisabled = isAirQualityOperatorDisabled();
const airActive = isAirQualityActive(accountEnabled);
const latestYear = Number(
(latestAir?.date ?? latest?.date ?? new Date().toISOString()).slice(0, 4),
);
const latestDay = latest
? {
date: latest.date,
tempMin: latest.tempMin,
tempMax: latest.tempMax,
apparentMax: latest.apparentMax,
// The air-quality part of the day only while the part is on, and
// only once it was fetched: an unfetched day says nothing, which is
// not the same as clean air.
airQuality:
airActive && latest.aqFetchedAt
? {
pm25Mean: latest.pm25Mean,
pm10Mean: latest.pm10Mean,
no2Mean: latest.no2Mean,
o3Max8h: latest.o3Max8h,
eaqiMax: latest.eaqiMax,
uvIndexMax: latest.uvIndexMax,
dustMax: latest.dustMax,
pollen: {
alder: latest.pollenAlderMax,
birch: latest.pollenBirchMax,
grass: latest.pollenGrassMax,
mugwort: latest.pollenMugwortMax,
olive: latest.pollenOliveMax,
ragweed: latest.pollenRagweedMax,
},
}
: null,
}
: null;
annotate({
action: { name: "environment.overview.read" },
meta: {
has_home: home !== null,
travel_count: travel.length,
context_days: contextCount,
air_quality_days: airDays,
air_quality_active: airActive,
last_fetch_failed: lastFetchFailure !== null,
},
});
@@ -92,10 +213,22 @@ export const GET = apiHandler(async () => {
latestDate: latest?.date ?? null,
latestFetchedAt: latest?.fetchedAt?.toISOString() ?? null,
},
airQuality: {
enabled: accountEnabled,
operatorDisabled,
days: airDays,
latestDate: latestAir?.date ?? null,
domain: latestAir?.aqDomain ?? null,
},
latestDay,
// null = the last background runs did not fail (or there is no queue to
// ask). Non-null names the failure so a zero day count is never left to
// stand alone. No error text: that message is written for an operator.
lastFetchFailure,
attribution: OPEN_METEO_ATTRIBUTION,
attributions: environmentAttributionLines({
airQuality: airActive,
year: latestYear,
}),
});
});
+17 -12
View File
@@ -6,6 +6,10 @@
* fallback). Coordinates are rounded to 1 decimal (about 11 km). Adding an override
* enqueues a lookback refresh so its days re-resolve. Module-gated; `userId` is
* narrowed from auth.
*
* v1.42 (#615) — the period's location is stored sealed
* (`locationEncrypted`); the readable columns are written empty and the
* answer carries the coarse location as before.
*/
import { NextRequest } from "next/server";
@@ -16,6 +20,7 @@ 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 { sealLocation } from "@/lib/environment/location-cipher";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -41,24 +46,24 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const entry = parsed.data;
const created = await prisma.environmentTravelLocation.create({
const location = {
lat: roundCoarse(entry.lat),
lon: roundCoarse(entry.lon),
label: entry.label,
};
const row = 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,
locationEncrypted: sealLocation(location),
lat: null,
lon: null,
label: null,
},
select: { id: true, startDate: true, endDate: true },
});
const created = { ...row, ...location };
// Re-resolve the affected window so the override's days pick up its weather.
await enqueueEnvironmentFetch({