Files
HealthLog/src/lib/openapi/routes/nutrients.ts
T
Marc-André Bombeck 38766cd4ca docs: define sharing refusal contracts
Every route that resolves a record through the sharing fence can answer 403
`sharing.access.denied`, and until now that fact was published on the selector
header and on none of the 120 operations that produce it. All 120 carry it now,
byte for byte, from one constant: the refusal is deliberately indistinguishable
across a missing account, a missing grant, a scope that does not reach the
surface and a read grant on a write, so 120 paraphrases would invite a client to
tell apart what the server refuses to.

Where a delegable route already answered 403 for its own reason — a module
gate, the cycle gate, AI consent — the two share one description rather than one
of them going unpublished, since OpenAPI allows a single response per status.

The guard recomputes the delegable set from the route sources on every run and
classifies per verb, which matters: seventeen modules have a delegable read
beside a write that refuses a switch outright.
2026-08-07 04:56:56 +02:00

158 lines
6.6 KiB
TypeScript

/**
* OpenAPI route table — nutrient intake sync (v1.28).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request
* and response bodies come from `src/lib/validations/nutrients.ts` so
* the wire contract stays single-source. The batch description embeds
* the code ↔ HealthKit-identifier ↔ unit table generated from the
* code-side catalog — the iOS client reads the pinned HK query unit
* per type from here (this is the documented home of that table).
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { NUTRIENT_DEFINITIONS } from "@/lib/nutrients/catalog";
import {
nutrientBatchSchema,
nutrientBatchResponseSchema,
nutrientDailyQuerySchema,
nutrientDailySeriesSchema,
nutrientOverviewQuerySchema,
nutrientOverviewSchema,
nutrientWaterWriteSchema,
nutrientWaterWriteResponseSchema,
} from "@/lib/validations/nutrients";
import {
dataEnvelope,
errorEnvelope,
recordRefusal,
stdResponses,
} from "./shared";
const NUTRIENTS_DISABLED_DESCRIPTION =
"The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is on.";
const moduleDisabled = {
"403": {
description: NUTRIENTS_DISABLED_DESCRIPTION,
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
/**
* The catalog table, rendered into the endpoint description so the
* OpenAPI file is the one authoritative home of the code ↔ HK ↔ unit
* contract (µg is `ug` on the wire; water is mL; never IU).
*/
const catalogTable = NUTRIENT_DEFINITIONS.map(
(d) => `| ${d.code} | ${d.hkIdentifier} | ${d.unit} |`,
).join("\n");
export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/nutrients/batch": {
post: {
tags: ["Nutrients"],
summary: "Ingest micronutrient day totals (v1.28)",
description:
"Bulk upsert of supplement-style daily totals synced from HealthKit — vitamins, minerals, water, caffeine. Day totals only: compute HKStatisticsCollectionQuery cumulative sums day-anchored in the user's current IANA timezone; `day` is yyyy-MM-dd in that timezone (the same rule as the `stats:` day keys). Never post raw samples. Upsert key is (day, nutrient): a re-post replaces the stored total and reports `updated`. Post the current + previous local day per sync; backfill 30 days on first enable. Max 500 entries; Idempotency-Key supported; rate limit 60/min. Per-entry skips (`unit_mismatch` | `value_out_of_range` | `day_invalid`) are terminal — log and drop, do not retry the entry. Behind the opt-in `nutrients` module (403 `module.disabled` when off). Query HealthKit with exactly the listed unit per code (`ug` = microgram; do not convert to IU):\n\n| code | HealthKit identifier | unit |\n| --- | --- | --- |\n" +
catalogTable,
requestBody: {
required: true,
content: { "application/json": { schema: nutrientBatchSchema } },
},
responses: {
"200": {
description:
"Batch processed; per-entry statuses inserted | updated | skipped.",
content: {
"application/json": {
schema: dataEnvelope(
nutrientBatchResponseSchema,
"NutrientBatchEnvelope",
),
},
},
},
...moduleDisabled,
...stdResponses,
},
},
},
"/api/nutrients": {
get: {
tags: ["Nutrients"],
summary: "Synced-nutrient window summary (v1.28)",
description:
"Per-nutrient summary of the last `days` days (default 14): latest synced day + total and the days-with-data count, in catalog order. Read-only; feeds the settings card. Behind the opt-in `nutrients` module.",
requestParams: { query: nutrientOverviewQuerySchema },
responses: {
"200": {
description: "The window summary.",
content: {
"application/json": {
schema: dataEnvelope(
nutrientOverviewSchema,
"NutrientOverviewEnvelope",
),
},
},
},
...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION),
...stdResponses,
},
},
},
"/api/nutrients/daily": {
get: {
tags: ["Nutrients"],
summary: "One nutrient's day-bucketed series (v1.29)",
description:
"Dense per-day series for one catalog code over the last `days` days (default 30) — one entry per calendar day, 0 for a day with no logged data, summed ACROSS SOURCES (a day may carry both an APPLE_HEALTH row and a MANUAL row since migration 0249). Feeds the `/insights/nutrients` hydration + caffeine charts. Also returns the EFSA reference resolved against the caller's profile sex — `null` when the profile has no sex on file (never guessed). Behind the opt-in `nutrients` module.",
requestParams: { query: nutrientDailyQuerySchema },
responses: {
"200": {
description: "The day-bucketed series + resolved reference.",
content: {
"application/json": {
schema: dataEnvelope(
nutrientDailySeriesSchema,
"NutrientDailySeriesEnvelope",
),
},
},
},
...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION),
...stdResponses,
},
},
},
"/api/nutrients/water": {
post: {
tags: ["Nutrients"],
summary: "Manual water quick-add (v1.29)",
description:
'Writes ONLY the `source="MANUAL"` row for `(day, "water")` — never the `source="APPLE_HEALTH"` row the batch endpoint owns, so a manual entry and an Apple sync coexist instead of one clobbering the other. `mode: "add"` increments the manual day total (quick-add chips); `mode: "set"` overwrites it (the "edit today\'s total" undo path — there is no per-entry ledger). `day` defaults to the caller\'s current local day when omitted. Idempotency-Key supported; rate limit 60/min. Behind the opt-in `nutrients` module.',
requestBody: {
required: true,
content: {
"application/json": { schema: nutrientWaterWriteSchema },
},
},
responses: {
"200": {
description: "The MANUAL water row after the write.",
content: {
"application/json": {
schema: dataEnvelope(
nutrientWaterWriteResponseSchema,
"NutrientWaterWriteEnvelope",
),
},
},
},
...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION),
...stdResponses,
},
},
},
};