mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Fetch the Open-Meteo air-quality feed (CAMS, domains=auto) with the weather: 17 hourly variables folded into local days by fixed rules (means and maxima only with 18 of 24 hours, ozone as the highest 8-hour running mean, UV only with the midday hours), a value the feed did not cover stored as null, never as zero. Requests are cut into 90-day chunks, newest first, and chunks before mid 2022 ask for the eight pollutant and index variables only. An air-quality failure never costs the weather row; the day stays unfetched and the nightly gap fill (up to 90 days per account, newest first) asks for it again. Every Open-Meteo request asks an instance-wide budget kept in Postgres first (500 a minute, 4000 an hour, 8000 a day, weighted by the hosted pricing rule). A refused request is not sent and the run ends as done with budget_blocked. OPENMETEO_AIR_QUALITY_URL and ENVIRONMENT_AIR_QUALITY_DISABLED are honoured; the account switch lives on PATCH /api/environment/preferences. The home, each dated location period and each stored day keep their coarse location sealed as one value under healthlog/environment-location/v1. The writers write the sealed value only, the readers open it and fall back to the readable columns for rows the free-text encryption backfill has not reached, and the backfill now seals all three and clears the readable columns. The backup carries the location readable in a portable file and sealed in a disaster-recovery file; a restore seals either under the receiving key. Three correlation channels join the environment family (PM2.5, the ozone 8-hour high, the day's highest pollen count), read through the averaged same-day window and left empty while air quality is off. The Coach and MCP gain get_environment: module-gated, clamped to the lookback limit, no coordinate or place name anywhere, with provenance and the attribution lines. The settings section gets the switch, the coverage and the newest day's values; the dashboard shows a quiet chip on a day with high pollen, a warm night or very poor air. Copernicus and Open-Meteo are credited next to the values.
235 lines
7.7 KiB
TypeScript
235 lines
7.7 KiB
TypeScript
/**
|
|
* `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.
|
|
*
|
|
* 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";
|
|
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";
|
|
|
|
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,
|
|
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 && homeLocation
|
|
? {
|
|
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).
|
|
since: profile.homeSince?.toISOString() ?? null,
|
|
}
|
|
: 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,
|
|
},
|
|
});
|
|
|
|
return apiSuccess({
|
|
home,
|
|
travel,
|
|
context: {
|
|
days: contextCount,
|
|
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,
|
|
}),
|
|
});
|
|
});
|