mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
`openapi:check` compares the registry against the committed YAML, so a route that was never registered produced no drift and no failure. These nineteen had shipped without a contract: the health probe, the dashboard analytics aggregate, the profile summary, personal records, the badge grid, the whole environmental-context module, both onboarding writes, the Apple Health import pair, the external medication ingest, the self-context adopt, the AI-provider update and the AI connection probe. Each entry is read off the route rather than inferred from a neighbour, so it carries what a client cannot guess: that `/api/health` answers a bare object and not the envelope, that `/api/analytics` records a score row and mints pattern ids on a GET, that `/api/ai/test` reports a provider failure as 200 with `ok: false` because a 5xx comes back as somebody's HTML error page, that the ingest route replays with 200 and creates with 201, and that a home location re-stamps its effective-from instant on every write. Three route-local request schemas move to `src/lib/validations/` so the published shape and the runtime parse are one object rather than two copies — a route module may only export handlers, which is why they could not be imported where they were. The AI-provider PATCH has no schema to move: it hand-rolls its parse field by field, so its entry says so and spells out that a wrongly-typed key is skipped in silence. The generated YAML is deliberately not in this commit.
379 lines
16 KiB
TypeScript
379 lines
16 KiB
TypeScript
/**
|
||
* 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 ~city granularity (2 dp) 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";
|
||
|
||
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 2 dp (~1 km)."),
|
||
lon: z.number().describe("Coarse longitude, rounded to 2 dp."),
|
||
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: z.iso
|
||
.datetime({ offset: true })
|
||
.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: z.iso.datetime({ offset: true }).nullable(),
|
||
}),
|
||
lastFetchFailure: z
|
||
.object({
|
||
lastFailedAt: z.iso
|
||
.datetime({ offset: true })
|
||
.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.",
|
||
),
|
||
})
|
||
.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 2 dp."),
|
||
lon: z.number().describe("Coarse longitude, rounded to 2 dp."),
|
||
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" });
|
||
|
||
export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||
"/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 2 dp 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.`,
|
||
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`.",
|
||
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 2 dp 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,
|
||
},
|
||
},
|
||
},
|
||
};
|