mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge environment data into release/v1.42.0
# Conflicts: # src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
commit
7bcb6f71d9
73 files changed
+6061
-385
No files matched your search
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user