/** * OpenAPI route table — the environmental-context module. * * Part of the OpenAPI route table; aggregated in `./index.ts`. Request shapes * come from `@/lib/validations/environment`, so the published contract and * the runtime parse are the same objects. * * The module stores one coarse home location plus any number of travel * overrides, and a nightly job resolves a day's weather against whichever of * them covers it. Everything on this surface is opt-in and module-gated: with * the `environment` module off, every one of the six paths answers 403 * `module.disabled` before it does anything else. * * Coordinates are rounded to 1 decimal (about 11 km) on the way in, as a * floor under whatever precision a client sends. That rounding is why the * geocode results and the stored home read back coarser than they were * picked. */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { environmentBackfillSchema, geocodeQuerySchema, homeLocationSchema, travelLocationSchema, } from "@/lib/validations/environment"; import { ENVIRONMENT_MAX_BACKFILL_DAYS } from "@/lib/environment/service"; import { dataEnvelope, errorEnvelope, moduleDisabledResponse, stdResponses, } from "./shared"; import { isoDateTime } from "@/lib/validations/datetime"; const homeLocationRequest = homeLocationSchema.meta({ id: "EnvironmentHomeRequest", description: "The coarse home location to store. `timezone` is checked against the runtime IANA set rather than accepted as free text — a bogus zone would mis-key every stored environment day for the account. Strict: an unknown key is a 422.", }); const travelLocationRequest = travelLocationSchema.meta({ id: "EnvironmentTravelRequest", description: "A manual travel override covering an inclusive [startDate, endDate] range. A declared trip dominates the home fallback for every day it covers. Strict, and `startDate` must be on or before `endDate`.", }); const backfillRequest = environmentBackfillSchema.meta({ id: "EnvironmentBackfillRequest", description: "The span to fetch. Both bounds are optional — omitting them asks for the conservative `[homeSince .. today]` range rather than a fixed reach into the past under the current home. Strict.", }); const geocodeQuery = geocodeQuerySchema.meta({ id: "EnvironmentGeocodeQuery", description: "Free-text place search, 1–120 characters after trimming.", }); const environmentHome = z .object({ lat: z .number() .describe("Coarse latitude, rounded to 1 decimal (about 11 km)."), lon: z.number().describe("Coarse longitude, rounded to 1 decimal."), label: z.string().nullable(), timezone: z .string() .describe( "The home's IANA zone. Falls back to the account's display timezone when the home was stored without one.", ), since: isoDateTime() .nullable() .describe( "Effective-from instant. Every set/update re-stamps it: a home resolves days from the moment it was set, and days before it stay un-attributed unless a travel override covers them.", ), }) .meta({ id: "EnvironmentHome", description: "The account's stored coarse home location.", }); const environmentTravel = z .object({ id: z.string(), startDate: z.string().describe("Inclusive YYYY-MM-DD."), endDate: z.string().describe("Inclusive YYYY-MM-DD."), lat: z.number(), lon: z.number(), label: z.string(), }) .meta({ id: "EnvironmentTravelLocation", description: "One manual travel override. Overlapping ranges are accepted — nothing on this surface refuses them.", }); const environmentOverview = z .object({ home: environmentHome .nullable() .describe("Null until a home has been set."), travel: z .array(environmentTravel) .describe("Every stored override, newest start date first."), context: z.object({ days: z .number() .int() .describe("Stored daily observations for this account."), latestDate: z .string() .nullable() .describe("Day key of the newest stored observation."), latestFetchedAt: isoDateTime().nullable(), }), lastFetchFailure: z .object({ lastFailedAt: isoDateTime().describe( "When the newest exhausted-retry failure gave up.", ), failures: z .number() .int() .describe( "Failures for this account on the queue inside the window.", ), }) .nullable() .describe( "Non-null when the background fetch has been failing, so a `days: 0` count is never left to stand alone as if it meant 'nothing to see'. Deliberately carries no error text: the operator's message can name internals. Also null when there is no queue to ask at all — absence of a queue is not a failing queue.", ), attribution: z .string() .describe( "Upstream attribution string. Display it wherever the observations are shown.", ), airQuality: z .object({ enabled: z .boolean() .describe( "The account's switch (`PATCH /api/environment/preferences`).", ), operatorDisabled: z .boolean() .describe( "True when the operator turned air quality off for the instance; the account switch then has no effect.", ), days: z .number() .int() .describe( "Stored days the air-quality feed returned hourly values for.", ), latestDate: z .string() .nullable() .describe("Newest such day (YYYY-MM-DD)."), domain: z .string() .nullable() .describe( "The model domain of that day: `cams_europe` (about 11 km, with pollen) or `cams_global` (about 45 km, no pollen).", ), history: z .object({ total: z .number() .int() .describe( "Past days with entries the source can serve at their place (from 2013 in Europe, from August 2022 elsewhere). Older days stay without values.", ), done: z .number() .int() .describe("Of those, the days whose air quality is stored."), complete: z .boolean() .describe("True when nothing the source serves is left."), checkedAt: z .string() .describe("ISO instant of the run that counted them."), }) .nullable() .describe( "Progress of the background backfill that fills air quality for every past day with entries. Null before its first run and while air quality is off.", ), }) .describe("v1.42 — the air-quality part of the module."), latestDay: z .object({ date: z.string().describe("YYYY-MM-DD of the newest stored day."), tempMin: z.number().nullable(), tempMax: z.number().nullable(), apparentMax: z .number() .nullable() .describe("Daily feels-like maximum, °C."), airQuality: z .object({ pm25Mean: z .number() .nullable() .describe("PM2.5 daily mean, µg/m³."), pm10Mean: z.number().nullable().describe("PM10 daily mean, µg/m³."), no2Mean: z.number().nullable().describe("NO2 daily mean, µg/m³."), o3Max8h: z .number() .nullable() .describe("Ozone, highest 8-hour running mean, µg/m³."), eaqiMax: z .number() .nullable() .describe("European air-quality index, daily maximum."), uvIndexMax: z.number().nullable(), dustMax: z.number().nullable().describe("Dust, µg/m³."), pollen: z .object({ alder: z.number().nullable(), birch: z.number().nullable(), grass: z.number().nullable(), mugwort: z.number().nullable(), olive: z.number().nullable(), ragweed: z.number().nullable(), }) .describe( "Daily maxima, grains/m³; null where not covered (outside Europe, before mid 2022).", ), }) .nullable() .describe( "Null while air quality is off for the account or the instance, and for a day whose air-quality part was not fetched yet. Null values inside mean not covered, never zero.", ), }) .nullable() .describe( "v1.42 — the newest stored day, usually yesterday or the day before (the archive settles late). No forecast. Null when nothing is stored.", ), attributions: z .array(z.string()) .describe( "v1.42 — every attribution line, weather first; the air-quality lines (Open-Meteo, Copernicus) while air quality is on. Display them wherever the values are shown.", ), }) .meta({ id: "EnvironmentOverview", description: "The module overview: home, travel overrides, a count of stored observations, and whether the background fetch is healthy.", }); const geocodeResult = z .object({ lat: z.number().describe("Coarse latitude, rounded to 1 decimal."), lon: z.number().describe("Coarse longitude, rounded to 1 decimal."), label: z .string() .describe( 'Assembled from name, region and country, e.g. "City, Region, Country".', ), timezone: z .string() .describe( 'IANA zone for the place, or the literal `"auto"` when the upstream feed reported none.', ), }) .meta({ id: "EnvironmentGeocodeResult" }); // v1.42 (#615) — the per-user environment switches. const environmentPreferencesRequest = z .object({ airQualityEnabled: z .boolean() .describe("Fetch and show air quality, pollen and UV for this account."), }) .strict() .meta({ id: "EnvironmentPreferencesRequest" }); const environmentPreferences = z .object({ airQualityEnabled: z.boolean(), operatorDisabled: z .boolean() .describe( "True when the operator turned air quality off for the whole instance; the account switch then has no effect.", ), }) .meta({ id: "EnvironmentPreferences" }); export const environmentPaths: NonNullable = { "/api/environment/preferences": { patch: { tags: ["Environment"], summary: "Change the environment switches", description: "Turns the air-quality part of the module on or off for this account (on by default). Off means the nightly fetch skips the air-quality feed, and the overview, the Coach, MCP and the correlations leave the air-quality values out; days already stored keep theirs. Turning it on queues a refresh, and the nightly gap fill catches up on older days. Module-gated. Strict body: an unknown key is a 422.", requestBody: { required: true, content: { "application/json": { schema: environmentPreferencesRequest }, }, }, responses: { "200": { description: "The stored switches.", content: { "application/json": { schema: dataEnvelope( environmentPreferences, "EnvironmentPreferencesEnvelope", ), }, }, }, ...moduleDisabledResponse, ...stdResponses, }, }, }, "/api/environment": { get: { tags: ["Environment"], summary: "Read the environmental-context overview", description: "The account's home location, its travel overrides, how many daily observations are stored, and whether the background fetch has been failing. Module-gated. Not delegable — this is the caller's own configuration.", responses: { "200": { description: "The module overview.", content: { "application/json": { schema: dataEnvelope( environmentOverview, "EnvironmentOverviewEnvelope", ), }, }, }, ...moduleDisabledResponse, ...stdResponses, }, }, }, "/api/environment/home": { put: { tags: ["Environment"], summary: "Set the coarse home location", description: "Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 1 decimal (about 11 km) server-side regardless of what was sent.\n\nRe-stamping is the part worth knowing: EVERY write moves `since` to now, including one that changes only the label. Days before the new `since` stop resolving against this home, so a correction to an existing home un-attributes the history that home used to cover.\n\nOn success a lookback fetch is enqueued so recent days populate without waiting for the nightly tick. That enqueue is best-effort — it no-ops cleanly when no worker is bound and the response is a 200 either way.\n\nBody cap 4 KiB. Module-gated.", requestBody: { required: true, content: { "application/json": { schema: homeLocationRequest } }, }, responses: { "200": { description: "The stored home, echoed back with its coarse values.", content: { "application/json": { schema: dataEnvelope( z.object({ home: environmentHome }), "EnvironmentHomeEnvelope", ), }, }, }, ...moduleDisabledResponse, "413": { description: "Body exceeds 4 KiB.", content: { "application/json": { schema: errorEnvelope } }, }, "415": { description: "`Content-Type` is not `application/json`.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, "422": { description: "Validation failed; `meta.errorCode` = `environment.invalid`, with every issue listed. An out-of-range coordinate, an empty or over-long label, an unknown key, or a `timezone` the runtime IANA set does not recognise all land here.", content: { "application/json": { schema: errorEnvelope } }, }, }, }, }, "/api/environment/backfill": { post: { tags: ["Environment"], summary: "Enqueue a historical environment fetch", description: `Asks the worker to fetch and store past days rather than waiting for the rolling nightly lookback. Returns 202 — the work has been QUEUED, not done; poll \`GET /api/environment\` and watch \`context.days\`.\n\nAn omitted bound defaults to the conservative \`[homeSince .. today]\` range. An explicit start earlier than \`homeSince\` is accepted and then resolves to a skip inside the worker: the span cannot fabricate weather for the pre-home past, so a wide range can legitimately return \`enqueued: true\` and add no days at all.\n\nThe span is capped at ${ENVIRONMENT_MAX_BACKFILL_DAYS} days, and the worker re-checks the cap independently. Body cap 4 KiB. Module-gated, and it draws on the shared analytics-read rate bucket.\n\nEvery backfill draws on the instance-wide Open-Meteo budget, so one account is held to three a rolling hour (429 \`environment.backfill_rate_limited\`), at most one queued backfill per twenty-minute slot (409 \`environment.backfill_pending\`), and, inside the worker, its own share of the daily request budget; days the share does not reach are filled by the nightly run.`, requestBody: { required: true, content: { "application/json": { schema: backfillRequest } }, }, responses: { "202": { description: "Accepted and queued. `enqueued` is false when no worker is bound to take it — the request still succeeded, nothing will run.", content: { "application/json": { schema: dataEnvelope( z.object({ enqueued: z.boolean(), startDate: z .string() .describe("The resolved start, after defaulting."), endDate: z .string() .describe("The resolved end, after defaulting."), spanDays: z.number().int(), }), "EnvironmentBackfillEnvelope", ), }, }, }, ...moduleDisabledResponse, "409": { description: "No home location is set, so there is nothing to resolve the days against (`meta.errorCode` = `environment.no_home`), or a backfill for this account is already queued in the current twenty-minute slot (`environment.backfill_pending`).", content: { "application/json": { schema: errorEnvelope } }, }, "413": { description: "Body exceeds 4 KiB.", content: { "application/json": { schema: errorEnvelope } }, }, "415": { description: "`Content-Type` is not `application/json`.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, "422": { description: "Validation failed. `meta.errorCode` = `environment.invalid` for a malformed body, an unknown key or a start after the end; `environment.range_too_large` when the resolved span exceeds the day cap.", content: { "application/json": { schema: errorEnvelope } }, }, }, }, }, "/api/environment/geocode": { get: { tags: ["Environment"], summary: "Search a place for the home-location picker", description: "Forward-geocodes free text to at most five coarse matches. The lookup runs server-side on purpose: the browser never talks to the upstream host, so the CSP needs no third-party entry.\n\nA failed or empty upstream answer is `results: []`, not an error — the picker shows 'no match' rather than a broken state, and the caller cannot distinguish 'nothing matched' from 'the feed was unreachable'.\n\nModule-gated, and it draws on the shared analytics-read rate bucket so a runaway autocomplete loop is capped.", requestParams: { query: geocodeQuery }, responses: { "200": { description: "Matches, possibly empty.", content: { "application/json": { schema: dataEnvelope( z.object({ results: z.array(geocodeResult) }), "EnvironmentGeocodeEnvelope", ), }, }, }, ...moduleDisabledResponse, ...stdResponses, "422": { description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`.", content: { "application/json": { schema: errorEnvelope } }, }, }, }, }, "/api/environment/travel": { post: { tags: ["Environment"], summary: "Add a travel override", description: "Declares that the account was somewhere other than home for an inclusive date range; the days it covers resolve against this location instead. Coordinates are rounded to 1 decimal (about 11 km) server-side.\n\nAdding one enqueues a refresh over exactly the declared window so its days re-resolve. Nothing checks for overlap with an existing override — two ranges covering the same day are accepted, and the worker picks between them.\n\nBody cap 4 KiB. Module-gated.", requestBody: { required: true, content: { "application/json": { schema: travelLocationRequest } }, }, responses: { "201": { description: "The stored override.", content: { "application/json": { schema: dataEnvelope( environmentTravel, "CreateEnvironmentTravelEnvelope", ), }, }, }, ...moduleDisabledResponse, "413": { description: "Body exceeds 4 KiB.", content: { "application/json": { schema: errorEnvelope } }, }, "415": { description: "`Content-Type` is not `application/json`.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, "422": { description: "Validation failed; `meta.errorCode` = `environment.invalid`. Covers an out-of-range coordinate, a malformed date, an unknown key, and `startDate` after `endDate`.", content: { "application/json": { schema: errorEnvelope } }, }, }, }, }, "/api/environment/travel/{id}": { delete: { tags: ["Environment"], summary: "Remove a travel override", description: "Owner-scoped: the delete matches on the id AND the caller's user id, so one account can never remove another's override — a cross-account id is a 404, same as one that never existed.\n\nHard delete, not a tombstone, and NOT idempotent in the usual sense: a second delete of the same id answers 404 rather than 200.\n\nRemoving an override does NOT re-resolve the days it used to cover; they keep whatever weather was stored for them until a backfill is asked for. Module-gated.", requestParams: { path: z.object({ id: z.string().describe("Travel-override id.") }), }, responses: { "200": { description: "Removed.", content: { "application/json": { schema: dataEnvelope( z.object({ deleted: z.boolean() }), "DeleteEnvironmentTravelEnvelope", ), }, }, }, ...moduleDisabledResponse, "404": { description: "No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, }, }, }, };