diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index b1b43b522..6c3abfaae 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -1,5 +1,4 @@ import type { NextRequest } from "next/server"; -import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { resolveProviderForTest, @@ -16,6 +15,7 @@ import { reserveBudget, resolveDailyCap, } from "@/lib/ai/coach/budget"; +import { aiTestOverrideSchema } from "@/lib/validations/ai-provider"; /** * Output ceiling of the probe below. Mirrors the `maxTokens` on the completion @@ -39,32 +39,6 @@ const ONE_DAY_MS = 24 * 60 * 60 * 1000; export const dynamic = "force-dynamic"; -const overrideSchema = z - .object({ - provider: z - .enum([ - "OPENAI", - "ANTHROPIC", - "LOCAL", - "OPENAI_COMPATIBLE", - "CHATGPT_OAUTH", - ]) - .optional() - .nullable(), - model: z.string().min(1).max(120).optional().nullable(), - baseUrl: z.string().url().max(2048).optional().nullable(), - anthropicKey: z.string().min(1).max(500).optional().nullable(), - localKey: z.string().min(1).max(500).optional().nullable(), - openaiKey: z.string().min(1).max(500).optional().nullable(), - // v1.33.1 (#470) — the OpenAI-compatible gateway's own fields, kept - // separate from `baseUrl` / `openaiKey` so testing an unsaved gateway - // config can never reach the pinned OpenAI arm and vice versa. - compatBaseUrl: z.string().url().max(2048).optional().nullable(), - compatKey: z.string().min(1).max(500).optional().nullable(), - compatModel: z.string().min(1).max(120).optional().nullable(), - }) - .strict(); - export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "ai.test" } }); @@ -93,7 +67,7 @@ export const POST = apiHandler(async (request: NextRequest) => { maxBytes: 64 * 1024, }); if (error) return error; - const parsed = overrideSchema.safeParse(data); + const parsed = aiTestOverrideSchema.safeParse(data); if (!parsed.success) { return apiError("Invalid override payload", 422); } diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index cd7eebdc7..4bfe35382 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -5,17 +5,7 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; - -const onboardingSchema = z.object({ - displayName: z.string().trim().min(1).max(50).optional(), - heightCm: z.number().min(50).max(300).optional(), - dateOfBirth: z.string().optional(), - // Same three values the profile schema stores. A narrower enum here - // rejected the whole onboarding submission over a field the account - // is allowed to hold, and left the value unrecordable until the person - // found the setting again afterwards. - gender: z.enum(["MALE", "FEMALE", "OTHER"]).optional(), -}); +import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; /** * Complete the onboarding flow. Saves optional profile data and marks @@ -30,7 +20,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; - const result = z.safeParse(onboardingSchema, body); + const result = z.safeParse(onboardingCompleteSchema, body); if (!result.success) { return apiError("Invalid input", 422, { errorCode: "onboarding.complete.invalid", diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts index 1080ce689..177e42831 100644 --- a/src/app/api/onboarding/step/route.ts +++ b/src/app/api/onboarding/step/route.ts @@ -1,5 +1,4 @@ import type { NextRequest } from "next/server"; -import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { @@ -14,10 +13,8 @@ import { prisma, toJson } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; -import { - ONBOARDING_GOAL_SLUGS, - buildGoalSeededDashboardLayout, -} from "@/lib/onboarding/goals"; +import { buildGoalSeededDashboardLayout } from "@/lib/onboarding/goals"; +import { onboardingStepSchema } from "@/lib/validations/onboarding"; /** * v1.4.25 W14b — POST /api/onboarding/step. @@ -45,19 +42,13 @@ import { * but tight enough to defang a stuck retry loop. */ -const stepBodySchema = z.object({ - step: z.number().int().min(1).max(4), - // v1.17.1 — optional goal slugs from the GoalsChipPicker (step 2 - // submit). Validated against the closed slug enum so an unknown slug - // 422s; deduped + capped at the full set size. Persisted to - // `User.onboardingGoals` (field-by-field, no mass assignment) and on - // completion (step 4) seeds the dashboard layout when the user never - // customized it. Omitted leaves the stored goals untouched. - goals: z - .array(z.enum(ONBOARDING_GOAL_SLUGS)) - .max(ONBOARDING_GOAL_SLUGS.length) - .optional(), -}); +// v1.17.1 — the optional goal slugs from the GoalsChipPicker (step 2 submit) +// ride the same body. Validated against the closed slug enum so an unknown +// slug 422s; persisted to `User.onboardingGoals` field-by-field (no mass +// assignment) and, on completion (step 4), used to seed the dashboard layout +// when the user never customized it. Omitted leaves the stored goals +// untouched. The schema itself lives in `@/lib/validations/onboarding` so the +// OpenAPI registry reads the same object this parse does. export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -83,7 +74,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; - const parsed = stepBodySchema.safeParse(body); + const parsed = onboardingStepSchema.safeParse(body); if (!parsed.success) { annotate({ action: { name: "onboarding.step" }, diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts new file mode 100644 index 000000000..6caa1c8a9 --- /dev/null +++ b/src/lib/openapi/routes/analytics.ts @@ -0,0 +1,543 @@ +/** + * OpenAPI route table — the dashboard analytics aggregate (`/api/analytics`). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * + * This is the web dashboard's one big read. Two shapes come out of the same + * path: `?slice=summaries` answers the tile strip from two SQL passes, and + * everything else answers the thick default body (BP-in-target windows, the + * glucose panel, the three fixed correlation hypotheses, the Health Score and + * the sleep-stage composition). One route, two response schemas, chosen by a + * query parameter — documented as a union rather than pretended into one + * object. + * + * Nothing here is `Derived` by accident: the score report and the weight + * goal are the shared derived-metrics union, so the four facets (value, + * coverage, confidence, provenance) are re-used from the insights module + * rather than restated. Those schemas are imported, not copied, so a change + * to the derived contract cannot leave this document behind. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types"; +import { + derivedConfidence, + derivedCoverage, + derivedProvenance, + glucoseClinicalSchema, +} from "./insights/schemas"; +import { dataEnvelope, stdResponses } from "./shared"; + +// ── The per-type summary the tile strip renders ────────────────────── + +const trendSlope = z + .object({ + slope: z.number().describe("Least-squares slope, in units per day."), + direction: z + .enum(["up", "down", "stable"]) + .describe("`stable` when |slope| < 0.01, not when the slope is zero."), + confidence: z + .number() + .describe("R² of the fit, 0–1. Reported as 0 when it cannot be formed."), + }) + .meta({ + id: "TrendSlope", + description: + "A linear trend over a trailing window. The window is anchored on NOW, not on the newest reading, so a series that stopped weeks ago reports `null` for the slope instead of describing a trend that has not been observed since.", + }); + +const dataSummary = z + .object({ + count: z.number().int(), + latest: z.number().nullable(), + min: z.number().nullable(), + max: z.number().nullable(), + mean: z.number().nullable(), + median: z + .number() + .nullable() + .describe( + "50th percentile over the trailing 90 days on this route (the SQL summaries path fixes that window). Linear-interpolated midpoint.", + ), + avg7: z.number().nullable(), + avg30: z.number().nullable(), + slope7: trendSlope.nullable(), + slope30: trendSlope.nullable(), + avg30LastMonth: z + .number() + .nullable() + .optional() + .describe( + "Mean over the 30-day window that ended 30 days ago — the prior period the tile's delta callout compares against.", + ), + avg30LastYear: z + .number() + .nullable() + .optional() + .describe( + "Mean over the 30-day window that ended 365 days ago. Populated only for types whose WEEK/MONTH/YEAR rollup tier reaches that far back; null otherwise, which is not the same as 'the year-ago window was empty'.", + ), + }) + .meta({ + id: "MetricDataSummary", + description: + "Per-measurement-type summary. Every statistic is null on an empty series rather than zero — a zero here would render as a real reading. `anomalyCount` and `slope90` left this wire in v1.37.19: the SQL path could never compute the first and nobody read the second.", + }); + +const summariesByType = z + .record(z.string(), dataSummary) + .describe( + "Keyed by `MeasurementType`. Only types the account has data for are present.", + ); + +const lastSeenByType = z + .record( + z.string(), + z + .object({ + lastSeenAt: z.iso.datetime({ offset: true }), + daysAgo: z + .number() + .int() + .describe( + "Whole days since `lastSeenAt`. Re-derived per request rather than served from the cache, so it stays right across a day boundary the cached body straddles.", + ), + }) + .nullable(), + ) + .describe( + "Per-type freshness, driving the 'last value N days ago' caption. A type the account has never logged reads `null`.", + ); + +const sleepSourceDiscrepancy = z + .object({ + deltaMinutes: z.number().int().nonnegative(), + sources: z.array( + z.object({ + source: z.string(), + deviceType: z.string().nullable(), + asleepMinutes: z.number().int().nonnegative(), + }), + ), + }) + .nullable() + .describe( + "Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session. Observational only — the served summary stays the winning writer's totals.", + ); + +// ── The slim slice ─────────────────────────────────────────────────── + +const analyticsSummariesSlice = z + .object({ + summaries: summariesByType, + bmi: z + .null() + .describe( + "Always null on this slice — BMI needs the profile height the slim path does not load. Re-derive it client-side from `summaries.WEIGHT.latest`, or read the default slice.", + ), + lastSeenByType, + sleepSourceDiscrepancy, + }) + .meta({ + id: "AnalyticsSummariesSlice", + description: + "What `?slice=summaries` answers: the per-type summaries and their freshness, and nothing thick. Four keys, and the caller gets them from two SQL passes rather than the default slice's chain.", + }); + +// ── Correlations ───────────────────────────────────────────────────── + +const correlationPoint = z.object({ x: z.number(), y: z.number() }); + +const correlationOk = z.object({ + kind: z.enum(["bp-compliance", "mood-pulse", "weight-weekday"]), + status: z.literal("ok"), + statistic: z + .number() + .describe( + "Pearson r for the two paired hypotheses; eta-squared for the weekday ANOVA.", + ), + n: z.number().int().describe("Paired rows behind the statistic."), + pValue: z.number(), + confidenceBand: z.object({ + low: z.number(), + high: z.number(), + label: z.enum(["low", "moderate", "high"]), + }), + interpretation: z + .string() + .describe( + "One conservative sentence, localised to the reader. Never claims causation. This is why the response cache is keyed per locale.", + ), + points: z.array(correlationPoint), + xLabel: z.string(), + yLabel: z.string(), + patternId: z + .string() + .optional() + .describe( + "Present once the pattern ledger has a row for this pair — the handle `PATCH /api/insights/correlations/patterns/{id}` takes.", + ), + canonicalKey: z.string().optional(), + dismissed: z + .boolean() + .optional() + .describe("True when the reader has dismissed this pattern."), +}); + +const correlationInsufficient = z.object({ + kind: z.enum(["bp-compliance", "mood-pulse", "weight-weekday"]), + status: z.literal("insufficient"), + n: z.number().int(), + reason: z.enum(["too_few_pairs", "not_significant", "no_variance"]), + points: z + .array(correlationPoint) + .describe( + "What was collected anyway, so the empty state can hint at progress.", + ), +}); + +const correlationResult = z + .union([correlationOk, correlationInsufficient]) + .meta({ + id: "FixedCorrelationResult", + description: + "One of the three fixed hypotheses. The `insufficient` arm is the normal case for a young account: the surface gate wants at least 20 paired rows and p < 0.05, and it says which of the two it missed.", + }); + +const analyticsCorrelations = z + .object({ + bpCompliance: correlationResult, + moodPulse: correlationResult, + weightWeekday: correlationResult, + degraded: z + .boolean() + .describe( + "Reserved for a future load-shedding branch and pinned to `false` by both current branches. Read it, do not assume it will ever be true today.", + ), + windowDays: z + .number() + .int() + .describe("Days the runner actually scanned. 28 on both branches."), + path: z + .enum(["rollup", "live"]) + .describe( + "`rollup` when the SYS / PULSE / WEIGHT series came off `measurement_rollups`. Mood and medication-intake reads are always live — there is no rollup equivalent for them.", + ), + }) + .meta({ + id: "AnalyticsCorrelations", + description: + "The three pre-defined correlation hypotheses over a 28-day window. Accepted results are also synced into the pattern ledger on this read, which is where `patternId` / `dismissed` come from.", + }); + +// ── Health Score ───────────────────────────────────────────────────── + +const scorePillarIdEnum = z.enum(SCORE_PILLAR_IDS); + +const pillarValue = z.object({ + score: z.number(), + observed: z.object({ + value: z.number(), + unit: z.string(), + label: z + .string() + .describe("The complete display value, paired or panel values included."), + asOf: z.iso.datetime({ offset: true }), + sources: z.array(z.string()), + }), + reference: z.object({ + kind: z.enum([ + "clinical-threshold", + "population-percentile", + "guideline-band", + ]), + low: z.number().nullable(), + high: z.number().nullable(), + label: z.string(), + source: z.string(), + }), + personalReference: z + .object({ + kind: z.enum([ + "clinical-threshold", + "population-percentile", + "guideline-band", + ]), + low: z.number().nullable(), + high: z.number().nullable(), + label: z.string(), + source: z.string(), + }) + .optional() + .describe( + "The reader's own yardstick, shown beside the scored reference. It is displayed, never scored — the pillar always grades against the clinical band.", + ), + scoreBasis: z + .object({ + axis: z.enum(["systolic", "diastolic"]), + relation: z.enum(["above_ceiling", "below_floor", "in_band"]), + offsetMmHg: z.number(), + boundaryMmHg: z.number(), + }) + .optional() + .describe( + "Blood pressure only, and only because that pillar takes the WORSE of two axes — the number alone cannot say which one produced it. A pillar with nothing of the kind to say omits the key.", + ), + noiseFloor: z + .number() + .describe("Score points a weekly move must clear before it is narrated."), + deltaEligible: z + .boolean() + .describe( + "False for slow markers: they set the level but never contribute to the weekly delta.", + ), + deltaIdentity: z + .string() + .describe( + "Input/scoring-mode identity. Two windows only form a delta when their identities match.", + ), +}); + +const compositeValue = z.object({ + score: z.number(), + band: z.enum(["green", "yellow", "red"]), + bandSetter: scorePillarIdEnum + .nullable() + .describe( + "The pillar that pulled the band down, non-null only when the worst pillar's band is below the mean score's.", + ), + composition: z + .array(scorePillarIdEnum) + .describe( + "Registry-ordered eligible pillars. Part of the number's identity: two scores built from different compositions are not comparable.", + ), + configured: z + .boolean() + .describe( + "True when the account's own recipe narrows the composition below what its defaults would resolve to today. The configuration itself never rides this wire.", + ), + noiseFloor: z.number(), + scoreVersion: z.number().int(), +}); + +const weightGoalValue = z.object({ + currentKg: z.number(), + target: z.object({ min: z.number(), max: z.number() }), + distanceKg: z.number(), + deltaKg: z + .number() + .nullable() + .describe("Positive means the distance to the personal band narrowed."), + asOf: z.iso.datetime({ offset: true }), + source: z.string(), +}); + +/** + * The `Derived` union, spelled out per payload. + * + * The gated arm still carries coverage and provenance so a surface renders + * "track N more days" rather than a blank — which is the whole reason the + * derived layer is a union and not a nullable value. + */ +function derived(value: T, id: string) { + return z + .union([ + z.object({ + status: z.literal("ok"), + value, + coverage: derivedCoverage, + confidence: derivedConfidence, + provenance: derivedProvenance, + }), + z.object({ + status: z.literal("insufficient"), + coverage: derivedCoverage, + provenance: derivedProvenance, + reason: z.string(), + }), + ]) + .meta({ id }); +} + +const healthScoreReport = z + .object({ + composite: derived(compositeValue, "DerivedHealthScoreComposite"), + pillars: z.array( + z.object({ + id: scorePillarIdEnum, + domain: z.enum([ + "cardiometabolic", + "activity", + "sleep", + "adiposity", + "wellbeing", + ]), + result: derived(pillarValue, "DerivedScorePillarValue"), + }), + ), + delta: z + .number() + .nullable() + .describe("Move against the previous window, or null when none forms."), + deltaReason: z + .enum([ + "algorithm_changed", + "config_changed", + "composition_changed", + "first_eligibility_window", + "below_noise_floor", + "no_previous_window", + "no_current_score", + ]) + .nullable() + .describe( + "Why there is no delta, or why the one there is should not be narrated as a health event. `config_changed` is the reader changing their own recipe mid-window — the arithmetic looks comparable and is not.", + ), + scoreVersion: z.number().int(), + weightGoal: derived(weightGoalValue, "DerivedWeightGoal"), + algorithmNotice: z + .object({ itemKey: z.string(), dismissed: z.boolean() }) + .nullable(), + restMode: z + .object({ + active: z.literal(true), + since: z.iso.datetime({ offset: true }).nullable(), + episodeCount: z.number().int(), + }) + .nullable() + .optional() + .describe( + "Attached when an illness episode was active as of the scored instant — the instant the score was computed for, not a fresh now. The score is NOT penalised; this frames it. Null when no episode was open. On this route the key is always present; it is optional in the shape because other producers of the report omit it.", + ), + }) + .meta({ + id: "HealthScoreReport", + description: + "The full Health Score report as the scorer produced it. Note this is the raw report, not the flattened hero block the dashboard snapshot serves — a client that reads both should not expect the same field names.", + }); + +// ── Sleep-stage composition ────────────────────────────────────────── + +const sleepStageComposition = z + .object({ + windowDays: z.number().int(), + nights: z + .number() + .int() + .describe("Nights inside the window carrying at least one stage entry."), + totalMinutes: z + .number() + .describe("Sum of every value in `stages`. Nights only, naps excluded."), + stages: z + .record(z.string(), z.number()) + .describe( + "Window totals per stage, keyed by the `SleepStage` enum. `IN_BED` is the session envelope and is carried alongside the asleep stages.", + ), + perNight: z.array( + z.object({ + dayKey: z + .string() + .describe("Wake-day key (YYYY-MM-DD) in the reader's timezone."), + stages: z.record(z.string(), z.number()), + napMinutes: z + .number() + .optional() + .describe( + "Time asleep across this day's naps. ABSENT on a day without one — there is no zero to render.", + ), + napCount: z.number().int().optional(), + }), + ), + }) + .nullable() + .describe( + "Trailing-30-day per-stage breakdown, or null when no session in the window carries stage data. Sessions are reconstructed and collapsed to one canonical source per night first, so a night written by two devices is counted once and a midnight-spanning night lands on its wake day.", + ); + +// ── The default slice ──────────────────────────────────────────────── + +const analyticsDefaultResponse = z + .object({ + summaries: summariesByType, + bmi: z + .number() + .nullable() + .describe( + "Derived from `summaries.WEIGHT.latest` and the profile height; null when either is missing.", + ), + bpInTargetPct: z + .number() + .nullable() + .describe("Trailing 90 days — the headline the BP tile shows."), + bpInTargetPct7d: z.number().nullable(), + bpInTargetPct30d: z.number().nullable(), + bpInTargetPctAllTime: z.number().nullable(), + bpInTargetPctPriorMonth: z.number().nullable(), + bpInTargetPctPriorYear: z.number().nullable(), + bpInTargetCount90: z + .number() + .int() + .nullable() + .describe("Readings behind the 90-day figure, for the thin-data gate."), + bpInTargetSpanDays90: z + .number() + .int() + .nullable() + .describe("Effective span the 90-day window actually covers."), + glucoseByContext: z + .record(z.string(), dataSummary) + .nullable() + .describe( + "Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME) over the trailing 30 days, canonical mg/dL. Contexts with no readings are omitted; the whole block is null when the glucose module is off.", + ), + glucoseClinical: glucoseClinicalSchema + .nullable() + .describe("Null when the glucose module is off for this account."), + correlations: analyticsCorrelations, + healthScore: healthScoreReport, + sleepStages: sleepStageComposition, + sleepSourceDiscrepancy, + lastSeenByType, + }) + .meta({ + id: "AnalyticsDefaultResponse", + description: + "The thick dashboard body. The BP figures are computed against the CLINICAL band even when the account keeps a personal target — the personal one is displayed beside the number and scores nothing.", + }); + +export const analyticsPaths: NonNullable = { + "/api/analytics": { + get: { + tags: ["Analytics"], + summary: "The dashboard analytics aggregate", + description: + "The web dashboard's aggregate read, in two shapes chosen by `?slice=`.\n\n`?slice=summaries` returns the slim tile-strip body from two SQL passes. ANY other value — including an absent, empty or misspelt one — returns the thick default body; the parameter is matched for the exact string `summaries` and is never rejected, so `?slice=summary` silently gets the expensive path.\n\nBoth shapes are served through a stale-while-revalidate cache. A cached body can be up to a minute old, and the default slice's cache key includes the reader's locale because the correlation `interpretation` sentences are narrated per reader. `Cache-Control: private, max-age=0, must-revalidate` on both — bfcache-friendly rather than the framework's stock `no-store`.\n\nThis GET is not free of side effects, and a caller polling it should know: it records a Health Score row for the scored instant, syncs accepted correlation hypotheses into the pattern ledger (which is what mints the `patternId` the dismissal endpoint takes), and kicks a background rollup-freshness refresh. The refresh is fire-and-forget by design — awaiting it stalled the event loop for tens of seconds on large accounts — so a value can be up to about a minute behind a measurement that just landed, and the next read has it.\n\nAuth is a cookie session or a wildcard (`*`) Bearer token; a narrow-scope token is refused. NOT delegable: it resolves the caller's own record, so a switched session still reads its own analytics.", + parameters: [ + { + name: "slice", + in: "query", + required: false, + schema: { type: "string", enum: ["summaries"] }, + description: + "`summaries` selects the slim tile-strip body. Any other value falls through to the default body rather than erroring.", + }, + ], + responses: { + "200": { + description: + "The analytics body. `AnalyticsSummariesSlice` for `?slice=summaries`, `AnalyticsDefaultResponse` otherwise.", + content: { + "application/json": { + schema: dataEnvelope( + z.union([analyticsDefaultResponse, analyticsSummariesSlice]), + "AnalyticsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/awards.ts b/src/lib/openapi/routes/awards.ts new file mode 100644 index 000000000..39b33f90b --- /dev/null +++ b/src/lib/openapi/routes/awards.ts @@ -0,0 +1,234 @@ +/** + * OpenAPI route table — personal bests and badges. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * + * Two reads that belong together: `PersonalRecord` rows (the detected bests + * per metric) and the derived achievement grid. Both are delegable — a + * delegate reads what the RECORD earned, never a copy of their own tally + * under the owner's name — and both are read-only in fact as well as in + * declaration. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { + measurementSourceEnum, + measurementTypeEnum, +} from "@/lib/validations/measurement"; +import { + MODULE_DISABLED_DESCRIPTION, + dataEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; + +const personalRecord = z + .object({ + id: z.string(), + userId: z + .string() + .describe( + "The RECORD's user id — under a delegated read this is the owner, not the caller.", + ), + metricType: measurementTypeEnum, + metricSlot: z + .string() + .nullable() + .describe( + 'Per-sport bucket for workout-driven bests (e.g. "running_5km_time"). Null for a plain measurement-driven best, where the metric type alone is the dimension.', + ), + direction: z + .enum(["MAX", "MIN"]) + .describe( + "Which way is better. MAX for steps / VO2 max / HRV / distance; MIN for resting heart rate, body fat, audio exposure.", + ), + value: z.number(), + unit: z.string(), + achievedAt: z.iso.datetime({ offset: true }), + sourceMeasurementId: z + .string() + .nullable() + .describe( + "The measurement that achieved the record. Nulled rather than cascaded when that measurement is later deleted, so the historical fact survives the reading behind it.", + ), + source: measurementSourceEnum, + externalId: z.string().nullable(), + createdAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "PersonalRecord", + description: + "One detected personal best. Rows are written by the detection worker, never by a client — there is no write endpoint on this resource.", + }); + +const achievement = z + .object({ + id: z.string(), + metric: z.string().describe("The counter this badge is graded against."), + category: z.enum([ + "medication", + "vitals", + "mood", + "security", + "engagement", + "hidden", + ]), + titleKey: z + .string() + .describe( + "i18n key. The default payload ships keys, not prose — the web client translates. `?format=ios` resolves them server-side instead.", + ), + descriptionKey: z.string(), + icon: z.string().describe("Lucide icon name."), + format: z.enum(["count", "days", "percent"]), + target: z.number(), + current: z.number(), + points: z.number().int(), + unlocked: z.boolean(), + progressPercent: z.number(), + completedAt: z.iso.datetime({ offset: true }).nullable(), + isHidden: z + .boolean() + .describe( + "An Easter-egg badge. While locked, the row is REDACTED to an opaque placeholder — the real title, description, icon and target are not on the wire until it unlocks.", + ), + }) + .meta({ + id: "Achievement", + description: + "One badge with its progress. A locked hidden badge carries placeholder copy and zeroed target/current: the spoiler shield is applied server-side, not left to the client.", + }); + +const achievementsWebResponse = z + .object({ + summary: z.object({ + unlockedCount: z.number().int(), + totalCount: z.number().int(), + earnedPoints: z.number().int(), + totalPoints: z.number().int(), + completionPercent: z + .number() + .int() + .describe("100 when the visible set is empty, not 0."), + nextAchievement: achievement + .nullable() + .describe("The closest badge still locked, redacted if hidden."), + }), + achievements: z.array(achievement), + metrics: z + .record(z.string(), z.unknown()) + .describe( + "The raw counters behind the badges. Counters that ONLY back a still-locked hidden badge are dropped from this map — the same spoiler shield the badge rows get. Partial by construction, so read defensively.", + ), + }) + .meta({ + id: "AchievementsResponse", + description: + "The badge grid as the web surface consumes it: i18n keys plus the summary tally and the underlying counters. Badges whose owning module the account has turned off are not evaluated and do not appear.", + }); + +const achievementIosEntry = z + .object({ + id: z.string(), + key: z + .string() + .describe("Same value as `id`; kept for the client's model."), + title: z.string().describe("Resolved in the caller's locale."), + description: z.string(), + iconName: z.string(), + unlocked: z.boolean(), + unlockedAt: z.iso.datetime({ offset: true }).nullable(), + progress: z.number().describe("0–1, clamped. Not the 0–100 percent."), + category: z.string(), + points: z.number().int(), + target: z.number(), + current: z.number(), + isHidden: z.boolean(), + }) + .meta({ + id: "AchievementIosEntry", + description: + "The flattened, pre-translated badge the native client renders. Note the differences from the web shape: translated strings instead of keys, `iconName` instead of `icon`, `unlockedAt` instead of `completedAt`, and `progress` as a 0–1 fraction.", + }); + +export const awardsPaths: NonNullable = { + "/api/personal-records": { + get: { + tags: ["Awards"], + summary: "List detected personal bests", + description: + "The record's personal bests, newest achievement first. Read-only — rows come from the detection worker and no endpoint creates or edits one.\n\nBoth query parameters are parsed defensively and NEVER 422: an unrecognised `metricType` is dropped (the response is then unfiltered rather than empty), and a `limit` that is not a positive integer falls back to the default rather than being rejected. A caller cannot tell a typo from a deliberate omission, so validate on your side.\n\nDelegable on a `measurements` grant.", + parameters: [ + { + name: "metricType", + in: "query", + required: false, + schema: { type: "string" }, + description: + "A `MeasurementType` to filter to. A value outside the enum is IGNORED, not refused.", + }, + { + name: "limit", + in: "query", + required: false, + schema: { type: "integer", minimum: 1, maximum: 500, default: 100 }, + description: + "Page cap, default 100, clamped at 500. A non-integer, zero or negative value silently reverts to 100; a value above 500 clamps down.", + }, + ], + responses: { + ...recordRefusal(), + "200": { + description: "The record's personal bests, newest first.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(personalRecord), + "ListPersonalRecordsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/gamification/achievements": { + get: { + tags: ["Awards"], + summary: "Read the badge grid", + description: + "Every badge with its progress, plus the summary tally. Two response shapes on one path: `?format=ios` returns a flat array of pre-translated entries, anything else returns the web object (`summary` / `achievements` / `metrics`) with i18n keys.\n\nEverything is derived from the record's own history, so a delegate sees what the owner earned. Badge categories whose owning module the account has disabled are skipped from evaluation entirely — they do not appear as locked, they do not appear at all.\n\nServed through a stale-while-revalidate cache with a 10-minute stale window, so a freshly crossed threshold can take a poll or two to surface. The cache holds the web shape; the iOS translation runs after the read, which is why switching locales does not need a separate cache entry.\n\nRead-only: since v1.35.3 the unlock rows are written by the `achievement-unlock-sweep` job rather than by this GET, and the payload is identical whether or not a row exists yet.\n\nDelegable on a whole-record grant — the grid spans every module that carries a badge category, so a per-section grant is not enough.", + parameters: [ + { + name: "format", + in: "query", + required: false, + schema: { type: "string", enum: ["ios"] }, + description: + "`ios` selects the flat, pre-translated array. Any other value (or none) returns the web object.", + }, + ], + responses: { + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + "200": { + description: + "The badge grid. `AchievementsResponse` by default, an array of `AchievementIosEntry` under `?format=ios`.", + content: { + "application/json": { + schema: dataEnvelope( + z.union([ + achievementsWebResponse, + z.array(achievementIosEntry), + ]), + "AchievementsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index ba397af86..71706c202 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -7,7 +7,10 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; -import { aboutMePutSchema } from "@/lib/validations/about-me"; +import { + aboutMeAdoptSchema, + aboutMePutSchema, +} from "@/lib/validations/about-me"; import { healthProfileAiSectionSchema, healthProfileFactCorrectionSchema, @@ -46,6 +49,7 @@ import { coachSuggestedActionSchema, } from "@/lib/validations/coach-reminder"; import { + MODULE_DISABLED_DESCRIPTION, baseUpdatedAtField, conflictResponse409, dataEnvelope, @@ -57,6 +61,15 @@ import { stdResponses, } from "./shared"; +// The assigning form, deliberately: `schema.meta({...})` as a bare statement +// returns a clone and registers nothing, so the component id would never +// reach the emitted document. +const aboutMeAdoptRequest = aboutMeAdoptSchema.meta({ + id: "CoachAboutMeAdoptRequest", + description: + "An answer to fold into the stored self-context. `question` is the clarifying question it belongs to and is what the server matches the target field from; omit it for the remember-a-message path and the field is matched from `answer` instead. `answer` is capped at 500 characters — the same cap as a structured self-context field.", +}); + emergencyProfileUpdateSchema.meta({ id: "UpdateEmergencyProfileRequest", description: @@ -1485,6 +1498,61 @@ export const coachPaths: NonNullable = { }, }, }, + "/api/coach/about-me/adopt": { + post: { + tags: ["Insights"], + summary: "Fold an answer back into the stored self-context", + description: + 'v1.16.4 — closes the clarifying-question loop. The Coach composer\'s chips insert a question the person answers in their own words; this endpoint moves that answer out of the chat transcript and into the matching structured self-context field.\n\n**Which field it lands on is inferred from the wording, not chosen by the caller.** The server keyword-matches allergy and condition stems across the six UI locales; anything else lands on `coachFocus`, the generic "worth knowing" slot. `question` is optional — the remember action on a chat message sends only the message text, and the field is then matched from that text. The chosen field is reported back so the client can say where it went.\n\n**Append, never replace.** Existing text stays and the answer joins on its own line, encrypted at rest. A structured field at its 500-character cap overflows into the free-text `aboutMe` instead of failing; only when THAT is full too does the write refuse.\n\n**A duplicate is a success, not an error.** An answer already contained in the target field or in `aboutMe` returns 200 with `adopted: false` and `reason: "duplicate"`, so tapping the offer twice cannot stack the same prose. Containment is checked case-insensitively on whitespace-collapsed text, so a near-identical rephrasing IS adopted as a second line.\n\nThe read-modify-write runs under a row lock, so two concurrent adoptions cannot lose an append or miss each other\'s dedupe. Audit rows carry the field name and the answer\'s LENGTH — never the text, which is free-form health prose. Coach-module-gated; rate-limited to 30 per minute per user; body cap 16 KiB.', + requestBody: { + required: true, + content: { "application/json": { schema: aboutMeAdoptRequest } }, + }, + responses: { + "200": { + description: + 'The adoption resolved. `adopted: true` means the text was appended; `adopted: false` with `reason: "duplicate"` means it was already there and nothing was written.', + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + adopted: z.boolean(), + field: z + .enum(["conditions", "allergies", "coachFocus", "aboutMe"]) + .describe( + "Where the answer went. `aboutMe` appears when the matched structured field was at its cap and the text overflowed into the free-text slot.", + ), + reason: z + .literal("duplicate") + .optional() + .describe("Present only on the `adopted: false` arm."), + }), + "CoachAboutMeAdoptResponse", + ), + }, + }, + }, + "403": { + description: MODULE_DISABLED_DESCRIPTION, + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 16 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "`Content-Type` is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "Either the body failed validation (every issue is listed), or the self-context is FULL — the matched field and the 4000-character `aboutMe` are both at their caps, so there is nowhere to put the answer. The two are told apart by the presence of the issue list.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/coach/plans": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/environment.ts b/src/lib/openapi/routes/environment.ts new file mode 100644 index 000000000..8d4c132ab --- /dev/null +++ b/src/lib/openapi/routes/environment.ts @@ -0,0 +1,378 @@ +/** + * 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 = { + "/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, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/health.ts b/src/lib/openapi/routes/health.ts new file mode 100644 index 000000000..21e35b7e7 --- /dev/null +++ b/src/lib/openapi/routes/health.ts @@ -0,0 +1,90 @@ +/** + * OpenAPI route table — the liveness/readiness probe. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * + * `/api/health` is the one route in the document that does NOT speak the + * `{ data, error }` envelope: it answers a bare object, because the compose + * healthcheck and every uptime monitor in front of a self-hosted instance + * read `status` directly. It is also the one route on the public-path + * allowlist in `src/proxy.ts` that a worker-only process still serves, so a + * deployment with no web role can still be probed. + * + * The response is not one shape but two, and which one a caller gets depends + * on who is asking — an authenticated ADMIN cookie session gets the component + * detail, everybody else gets the single word. That is deliberate (the detail + * names the database and worker state) and it is why the 200 below documents + * a union rather than a single object. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +const healthStatusEnum = z.enum(["ok", "degraded"]).meta({ + id: "HealthStatus", + description: + "`ok` when the database answers AND every role this process runs holds up: the pg-boss producer is bound when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing.", +}); + +const healthPublicResponse = z.object({ status: healthStatusEnum }).meta({ + id: "HealthPublicResponse", + description: + "What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell.", +}); + +const healthAdminResponse = z + .object({ + status: healthStatusEnum, + timestamp: z.iso + .datetime({ offset: true }) + .describe("When the probe ran. UTC (`Z`) — no user context is loaded."), + database: z + .enum(["connected", "disconnected"]) + .describe("Result of a `SELECT 1` round-trip."), + worker: z + .enum(["running", "stopped"]) + .describe( + "The in-process pg-boss worker loop. Reported for every process, including one that does not run the worker role — a web-only process reads `stopped` here and can still be `ok`.", + ), + workerLastHeartbeat: z.iso + .datetime({ offset: true }) + .optional() + .describe( + "Last worker heartbeat. Absent when the worker has never beaten in this process.", + ), + }) + .meta({ + id: "HealthAdminResponse", + description: + "The component breakdown, returned ONLY to a caller whose cookie session resolves to an ADMIN. A Bearer token never reaches this arm: the route resolves the session directly through `getSession()`, which reads the cookie.", + }); + +export const healthPaths: NonNullable = { + "/api/health": { + get: { + tags: ["Meta"], + summary: "Liveness / readiness probe", + description: + "Reports whether this process can serve. Public — it is on the proxy's allowlist and needs no credential, which is what lets `docker compose` and an external monitor use it. It is also the ONLY path a worker-only process answers; every other route on such a process is refused.\n\nThe body is a BARE object, not the `{ data, error }` envelope every other route returns. Do not run it through the envelope decoder.\n\nThe status code carries the verdict as well as the body: 200 for `ok`, 503 for `degraded`. Probe on the code; the body is for a human reading the response.\n\nAn ADMIN cookie session gets the component breakdown (`database`, `worker`, `timestamp`); everybody else gets `{ status }` alone. `Cache-Control: no-store, no-cache, must-revalidate` on both arms.", + responses: { + "200": { + description: + 'Healthy. `{ status: "ok" }`, plus the component breakdown when the caller is an admin.', + content: { + "application/json": { + schema: z.union([healthAdminResponse, healthPublicResponse]), + }, + }, + }, + "503": { + description: + "Degraded — the database did not answer, or a role this process runs is not up. Same body shape as the 200, with `status: \"degraded\"`. A restart loop reports this rather than failing to answer, so a monitor can tell 'starting' from 'gone'.", + content: { + "application/json": { + schema: z.union([healthAdminResponse, healthPublicResponse]), + }, + }, + }, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index 7b3e13415..d64afc87e 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -4,10 +4,12 @@ * * The CSV importer is the cold-start escape hatch for self-hosters migrating * from a spreadsheet or another tracker. The JSON importer (`/api/import`) - * restores the structure `GET /api/export/json` emits. The Apple-Health - * route (`/api/import/apple-health-export`) predates the contract surface - * and remains undocumented here. iOS calls neither — its canonical ingest is - * `POST /api/measurements/batch`. + * restores the structure `GET /api/export/json` emits. The Apple-Health pair + * (`/api/import/apple-health-export` + its status poll) takes the `export.zip` + * the Health app produces and hands it to a background worker; iOS does not + * call it — the native client's canonical ingest is + * `POST /api/measurements/batch`, and this route exists for the person who + * exports from the phone and uploads through the browser. */ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; @@ -258,4 +260,146 @@ export const importPaths: NonNullable = { }, }, }, + "/api/import/apple-health-export": { + post: { + tags: ["Import"], + summary: "Upload an Apple Health export.zip for background import", + description: + "Takes the `export.zip` the Health app produces and hands it to a background worker. The response is **202 with a `jobId`** — nothing has been imported yet. Poll `GET /api/import/apple-health-export/{jobId}/status` for the outcome.\n\nBody is `multipart/form-data` with the file under the field name `file`, capped at 1.5 GB. The upload is streamed straight to disk rather than buffered, computing a SHA-256 in the same pass; the declared `Content-Length` is checked first as a cheap pre-flight, but the streaming sink enforces the cap independently, so a missing or lying header does not get past it.\n\n**Idempotency is by CONTENT, not by an `Idempotency-Key`.** A 1 GB upload has no stable client-side key, so re-uploading the exact same bytes resolves to the previous job and returns `idempotent: true` without re-queueing. The match only covers a job that is still viable — queued, in flight, or done. A previously FAILED job is deliberately not matched, so the same export can always be retried with the same bytes rather than being tombstoned by its own failure.\n\nRate-limited to 3 uploads per minute per user, checked before the body is touched. Cookie or wildcard Bearer.", + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: z.object({ + file: z + .string() + .describe( + "The Apple Health `export.zip`, up to 1.5 GB. The field name is load-bearing — a part under any other name is not found and the request fails as a malformed upload.", + ), + }), + }, + }, + }, + responses: { + "202": { + description: + "Accepted and queued (or matched to a job already carrying these bytes). `status` is `queued` for a fresh job and the existing job's current status on a content match.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + jobId: z.string(), + status: z + .enum([ + "queued", + "unpacking", + "parsing", + "upserting", + "done", + ]) + .describe( + "`queued` on a fresh enqueue. On a content match it is whatever the matched job is doing now — never `failed`, because a failed job is not matched.", + ), + idempotent: z + .boolean() + .optional() + .describe( + "`true` only when these exact bytes matched a still-viable job and nothing was queued. ABSENT (not `false`) on a fresh enqueue.", + ), + }), + "AppleHealthImportKickoffEnvelope", + ), + }, + }, + }, + "400": { + description: "The request carried no body at all.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: + "The declared `Content-Length` exceeds 1.5 GB. A body that exceeds the cap without declaring it is refused by the streaming sink instead, which surfaces as the 422 below.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "The multipart body could not be streamed to disk — no `file` part, a malformed boundary, or the size cap tripped mid-stream. The message names the failure.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "More than 3 uploads in a minute for this user.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "503": { + description: + "No background worker is bound to this process, so the job cannot be queued. The upload was staged and is discarded; retry once a worker is running.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/import/apple-health-export/{jobId}/status": { + get: { + tags: ["Import"], + summary: "Poll an Apple Health import job", + description: + "The state of one import job. The web client polls every 2 seconds while the job is running and every 30 seconds once it is terminal.\n\nVisible to the job's owner, and to the admin who triggered it through the admin variant. Anyone else gets **404, not 403** — existence is not leaked to an outsider, so a 404 means either 'no such job' or 'not yours' and a client cannot tell which.\n\nThe `result` block is scrubbed before it is served: the ECG counters are re-read as bounded non-negative integers so a malformed worker payload cannot put arbitrary values on this wire.", + requestParams: { + path: z.object({ + jobId: z.string().describe("The id returned by the upload."), + }), + }, + responses: { + "200": { + description: "The job's current state.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + jobId: z.string(), + status: z + .string() + .describe( + "`queued`, `unpacking`, `parsing`, `upserting`, `done` or `failed`. A plain string on the wire, not a closed enum — the column is untyped and a future phase name would arrive here without a schema change.", + ), + startedAt: z.iso.datetime({ offset: true }), + completedAt: z.iso.datetime({ offset: true }).nullable(), + uploadBytes: z.number().int(), + exportedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "The instant the Health app stamped on the export itself, once the worker has read it. Null before then.", + ), + progress: z + .record(z.string(), z.unknown()) + .describe( + "Free-form phase progress written by the worker. `{}` before the first phase reports. Shape is not part of the contract.", + ), + result: z + .record(z.string(), z.unknown()) + .nullable() + .describe( + "The worker's outcome summary, null until it finishes. Free-form except for `ecg`, whose five counters (`discovered` / `imported` / `updated` / `skipped` / `failed`) are normalised to bounded non-negative integers before serving.", + ), + failureReason: z + .string() + .nullable() + .describe("Free text on `failed`; null otherwise."), + }), + "AppleHealthImportStatusEnvelope", + ), + }, + }, + }, + "404": { + description: + "No such job, or one the caller may not see. The two are indistinguishable on purpose.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 1d053c5ee..a56ad1aee 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -29,7 +29,12 @@ import { } from "./account-sharing"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; import { allergyPaths } from "./allergies"; +import { analyticsPaths } from "./analytics"; import { authPaths } from "./auth"; +import { awardsPaths } from "./awards"; +import { environmentPaths } from "./environment"; +import { healthPaths } from "./health"; +import { ingestPaths } from "./ingest"; import { coachFeedbackPaths, coachPaths, @@ -128,6 +133,15 @@ export const openApiPaths: NonNullable = { // v1.36.0 — account sharing: the grant lifecycle and the switch endpoint // (appended, spread order is load-bearing). ...accountSharingPaths, + // Routes that existed for releases without ever reaching the registry, so + // `openapi:check` saw no drift and reported nothing: it compares the + // registry against the committed YAML and has never compared the routes on + // disk against the registry. Appended, spread order is load-bearing. + ...healthPaths, + ...analyticsPaths, + ...awardsPaths, + ...environmentPaths, + ...ingestPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/ingest.ts b/src/lib/openapi/routes/ingest.ts new file mode 100644 index 000000000..d8dad6ebc --- /dev/null +++ b/src/lib/openapi/routes/ingest.ts @@ -0,0 +1,101 @@ +/** + * OpenAPI route table — the external medication-ingest endpoint. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The request + * body is the runtime `externalIntakeSchema`; the response is the shared + * `MedicationIntakeEvent` resource the medications module already publishes, + * so the two surfaces cannot describe the same row differently. + * + * This route is its own module because its AUTH is its own thing. It is one + * of the three surfaces that resolve a Bearer token OUTSIDE `requireAuth` + * (`src/__tests__/bearer-scope-enforcement-guard.test.ts` freezes the set), + * and it is the only one that a home-automation bridge is meant to call. So + * the token rules it applies are written out here rather than left to the + * generic security scheme: no cookie session reaches it, and the scope check + * is a two-stage one the other Bearer surfaces do not have. + */ +import type { ZodOpenApiObject } from "zod-openapi"; + +import { externalIntakeSchema } from "@/lib/validations/medication"; +import { medicationIntakeEventResource } from "./medications/schemas"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +const externalIntakeRequest = externalIntakeSchema.meta({ + id: "ExternalMedicationIntakeRequest", + description: + "An externally-observed dose. `medicationName` is matched case-insensitively against the token owner's ACTIVE medications — there is no id form on this surface. `takenAt` is optional and defaults to now, bounded by the same plausibility range the interactive create paths use. `idempotencyKey` is an identity, not a windowed replay token: it is stored on the row forever and matched by equality, so a key that rotates per client launch never matches its own earlier row and every re-sync logs the dose again.", +}); + +export const ingestPaths: NonNullable = { + "/api/ingest/medication": { + post: { + tags: ["Medications"], + summary: "Record a dose from an external integration", + description: + "Logs a medication intake from outside the app — a chat bridge, a home-automation rule, a button on a shelf.\n\n**Auth is Bearer only.** A cookie session does not authenticate here: the route reads the `Authorization` header itself, hashes it and looks the token up, so a browser with a valid login and no token gets 401. The token must carry either the `*` wildcard or the `medication:ingest` scope — AND, once the named medication resolves, either `*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup, which is why a token scoped to one medication gets 404 for a name it may not touch only when the name does not exist, and 403 when it does.\n\n**The operator can switch this off.** With the global API kill switch disabled every request is refused with 403 before the token is even read.\n\n**Replay is by `idempotencyKey`, and the status says which happened.** A key already stored for this token's owner returns the existing row with **200**; a fresh dose returns **201**. Nothing else distinguishes them — same body shape, same fields — so branch on the status code.\n\n**A dose is attributed to its scheduled slot, not stamped ad-hoc.** When the take falls inside a schedule's window band it converges onto that slot's canonical row (closing the pending reminder rather than leaving it open beside a duplicate ad-hoc take); otherwise it lands as a standalone row with `scheduledFor = takenAt`. Only a genuine pending-to-taken transition consumes inventory, so a re-post that converges onto an already-taken slot does not decrement stock twice.\n\nRate-limited to 60 requests per minute **per client IP**, not per token — several bridges behind one NAT share the bucket. The limit is applied before the token is read, so an unauthenticated flood is capped too.", + requestBody: { + required: true, + content: { "application/json": { schema: externalIntakeRequest } }, + }, + responses: { + "201": { + description: "The dose was recorded. Body is the created row.", + content: { + "application/json": { + schema: dataEnvelope( + medicationIntakeEventResource, + "ExternalMedicationIntakeCreatedEnvelope", + ), + }, + }, + }, + "200": { + description: + "Replay: a row with this `idempotencyKey` already exists for the token's owner and is returned unchanged. Nothing was written and no inventory was consumed.", + content: { + "application/json": { + schema: dataEnvelope( + medicationIntakeEventResource, + "ExternalMedicationIntakeReplayEnvelope", + ), + }, + }, + }, + ...stdResponses, + "401": { + description: + "No `Authorization: Bearer …` header, or a token that is unknown, revoked or past its expiry. The three cases are not distinguished on the wire.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "403": { + description: + "Refused. Either the operator has the global API switch off (the message says so), or the token lacks `medication:ingest` / `*`, or it lacks the resolved medication's own `medication:{id}:ingest` / `*` scope.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: + "No ACTIVE medication of the token owner's matches `medicationName`. An inactive medication of the right name is also a 404 — the name is only searched among active ones.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "`Content-Type` is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 60 requests in a minute from this client IP. Carries the standard rate-limit headers. Note the body is the bare `{ data: null, error }` pair without `meta`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts index ddbb88179..2d7b3b66a 100644 --- a/src/lib/openapi/routes/onboarding.ts +++ b/src/lib/openapi/routes/onboarding.ts @@ -1,9 +1,19 @@ /** - * OpenAPI route table — onboarding module tour. + * OpenAPI route table — the onboarding wizard and the module tour. * * Part of the OpenAPI route table; aggregated in `./index.ts`. - * The request schema comes from `src/lib/onboarding/tour-progress.ts` - * so the wire contract stays single-source with the runtime parser. + * The request schemas come from `src/lib/onboarding/tour-progress.ts` and + * `src/lib/validations/onboarding.ts` so the wire contract stays + * single-source with the runtime parsers. + * + * Two generations of the same flow live side by side here, and a client + * should pick one. `POST /api/onboarding/step` is the current wizard: four + * ordered checkpoints, the fourth of which completes. `POST + * /api/onboarding/complete` is the older single-shot path that stamps the + * completion and takes whatever profile fields it was given. They write the + * same column and refuse to co-operate — the step route's conditional update + * requires `onboardingCompletedAt: null`, so a `complete` call mid-wizard + * makes every remaining step 409. * * v1.18.6 — the resumable module-tour contract the iOS client mirrors: * a fire-and-forget progress checkpoint plus the coarse completion @@ -14,7 +24,11 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; -import { dataEnvelope, stdResponses } from "./shared"; +import { + onboardingCompleteSchema, + onboardingStepSchema, +} from "@/lib/validations/onboarding"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; const tourProgressResource = tourProgressSchema.meta({ id: "TourProgress", @@ -84,7 +98,123 @@ const disclaimerAckResponse = z description: "The persisted disclaimer acknowledgment version.", }); +const onboardingStepRequest = onboardingStepSchema.meta({ + id: "OnboardingStepRequest", + description: + "The wizard step being COMPLETED, 1–4. It must equal the stored step plus one — the server does not clamp or skip. `goals` rides the step-2 submit; every slug is checked against the closed set, so one unknown slug fails the whole request rather than being dropped.", +}); + +const onboardingStepResponse = z + .object({ + step: z.number().int().describe("The stored step after the write."), + onboardingCompletedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("Non-null once step 4 landed."), + }) + .meta({ id: "OnboardingStepResponse" }); + +const onboardingCompleteRequest = onboardingCompleteSchema.meta({ + id: "OnboardingCompleteRequest", + description: + "Optional profile fields to save alongside the completion stamp. Every field is optional and only a truthy value is written — sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing it. There is no way to CLEAR a field through this endpoint. `dateOfBirth` is a free string parsed with `new Date(...)`: an unparseable value is silently ignored, not refused.", +}); + export const onboardingPaths: NonNullable = { + "/api/onboarding/step": { + post: { + tags: ["Onboarding"], + summary: "Advance the onboarding wizard by one step", + description: + "Persists progress through the four-step wizard. The contract is strictly ordered: the submitted `step` must be exactly the stored step plus one, so a client cannot skip ahead or replay a step it already sent.\n\nSubmitting step 4 completes onboarding — the completion instant is stamped in the same write, the proxy-readable pending cookie is cleared so the next navigation stops redirecting to `/onboarding`, and the stored goal selection seeds the dashboard layout. That seed is ONE-TIME and conditional: it only runs while the layout column is still unset, so a person who already arranged their tiles is never clobbered, and a concurrent layout save that lands first wins.\n\nThe write is guarded on the state it validated, so two tabs submitting the same step do not both succeed — exactly one lands and the other gets 409.\n\nRate-limited to 30 writes per 10 minutes per user, which is generous for a four-step flow and tight enough to defang a stuck retry loop. Every accepted call writes an audit row. Cookie or wildcard Bearer; `userId` is never read from the body.", + requestBody: { + required: true, + content: { "application/json": { schema: onboardingStepRequest } }, + }, + responses: { + "200": { + description: "The stored step and completion stamp after the write.", + content: { + "application/json": { + schema: dataEnvelope( + onboardingStepResponse, + "OnboardingStepEnvelope", + ), + }, + }, + }, + "404": { + description: + "The session's user row no longer exists. `meta.errorCode` = `onboarding.user.notFound`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The write was refused and nothing changed. `meta.errorCode` distinguishes three cases: `onboarding.step.completed` — onboarding is already finished, so the wizard has nothing left to advance; `onboarding.step.outOfOrder` — the submitted step is not the stored step plus one, and the message names the current step; `onboarding.step.concurrent` — the row moved between the check and the write, which is what a second tab sees.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "`Content-Type` is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "The body did not validate. `meta.errorCode` = `onboarding.step.invalid`. Single-message, not the multi-issue envelope — the per-field detail is not on the wire.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 30 onboarding writes in 10 minutes. `meta.errorCode` = `onboarding.step.rateLimited`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/onboarding/complete": { + post: { + tags: ["Onboarding"], + summary: "Stamp onboarding as complete (legacy single-shot path)", + description: + "Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nThe older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it writes no audit row and enforces no rate limit of its own. It also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing call.\n\nCalling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null.\n\nCookie or wildcard Bearer; `userId` is never read from the body.", + requestBody: { + required: true, + content: { "application/json": { schema: onboardingCompleteRequest } }, + }, + responses: { + "200": { + description: + "Onboarding stamped complete. The body is the fixed `{ completed: true }` acknowledgement — it does not echo what was saved.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ completed: z.literal(true) }), + "OnboardingCompleteEnvelope", + ), + }, + }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "`Content-Type` is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "The body did not validate. `meta.errorCode` = `onboarding.complete.invalid`. Single-message, not the multi-issue envelope.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/onboarding/disclaimer": { post: { tags: ["Onboarding"], diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 4ad9069a5..a8c96953b 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -14,6 +14,14 @@ import { modulePrefsPatchSchema } from "@/lib/validations/modules"; import { MODULE_KEYS } from "@/lib/modules/registry"; import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types"; import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape"; +import { + AI_PROVIDER_KINDS, + aiTestOverrideSchema, +} from "@/lib/validations/ai-provider"; +import { + HEALTH_PROFILE_FACT_KINDS, + healthProfileFactKindSchema, +} from "@/lib/validations/health-profile-facts"; import { dataEnvelope, errorEnvelope, @@ -22,6 +30,7 @@ import { updatedAtTokenField, conflictResponse409, invalidBaseTokenResponse, + recordRefusal, } from "./shared"; // v1.18.0 — module enable/disable. The PATCH request is the REAL runtime @@ -509,7 +518,233 @@ const aiProviderResponse = z "The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). Reports key presence + a masked preview only — never a plaintext key.", }); +// The PATCH body is documented here rather than imported from a validations +// module because there is nothing to import: the route hand-rolls its parse +// field by field instead of running a Zod `safeParse`, so the shape below is +// read off that code. Two consequences a client needs to be told, because no +// generated schema would carry them: a key of the wrong TYPE (a number +// `model`, a boolean `baseUrl`) is silently ignored rather than refused, and +// a body containing only such keys fails as "No valid fields" rather than +// naming the offender. +const aiProviderPatchRequest = z + .object({ + provider: z + .enum(AI_PROVIDER_KINDS) + .nullable() + .optional() + .describe( + 'The provider to select. `null` or `""` clears it. Any other unrecognised value is a 422 — this is the one field whose bad value is refused rather than skipped.', + ), + model: z + .string() + .nullable() + .optional() + .describe('Trimmed; `null`, `""` or whitespace-only clears it.'), + baseUrl: z + .string() + .nullable() + .optional() + .describe( + 'Custom base URL for the LOCAL provider. `null` or `""` clears it. A non-empty value runs the SSRF floor: a private or internal host is refused with 422 unless the operator allowlisted that exact host. NOTE this column is shared — switching `provider` away from LOCAL in the same request clears it automatically, so a stored cloud key can never be sent to a host configured for something else.', + ), + compatBaseUrl: z + .string() + .nullable() + .optional() + .describe( + "Base URL of the OpenAI-compatible gateway. Its own column, deliberately not shared with `baseUrl`. Same SSRF floor.", + ), + compatModel: z.string().nullable().optional(), + compatKey: z + .string() + .nullable() + .optional() + .describe( + 'Bearer for the gateway; encrypted at rest. `null` or `""` clears it. Never echoed back — the read reports presence only.', + ), + anthropicKey: z + .string() + .nullable() + .optional() + .describe('Encrypted at rest. `null` or `""` clears it.'), + localKey: z + .string() + .nullable() + .optional() + .describe('Encrypted at rest. `null` or `""` clears it.'), + openaiKey: z + .string() + .nullable() + .optional() + .describe('Encrypted at rest. `null` or `""` clears it.'), + responseTimeoutSeconds: z + .number() + .int() + .min(10) + .max(600) + .nullable() + .optional() + .describe( + "Per-user AI response timeout in seconds, 10–600. `null` restores the built-in default (~120 s). A number outside the range, or a non-integer, is a 422.", + ), + }) + .meta({ + id: "AiProviderPatchRequest", + description: + "Partial update of the calling user's AI-provider configuration. An omitted key is untouched; an explicit `null` (or empty string, on the string fields) clears the column. At least one recognised key must be present. Key material is write-only: it is encrypted on the way in and never returned.", + }); + +const aiTestRequest = aiTestOverrideSchema.meta({ + id: "AiTestRequest", + description: + "An UNSAVED provider selection to probe. Every field is optional and falls back to the stored configuration, so a user with a saved key can change only the model and test it without re-typing the credential. Nothing here is persisted. Strict: an unknown key is a 422.", +}); + +const aiTestResponse = z + .union([ + z.object({ + ok: z.literal(true), + providerType: z.string(), + model: z.string().nullable(), + tokensUsed: z.number().int().nullable(), + sample: z + .string() + .describe( + "First 200 characters of the provider's reply to the probe prompt. Model output, not a credential.", + ), + }), + z.object({ + ok: z.literal(false), + providerType: z.string(), + reasonCode: z.enum([ + "credentials", + "rate_limited", + "server_error", + "bad_request", + "unreachable", + ]), + reason: z + .string() + .describe( + "English fallback prose for the code. Secret-free by construction — the provider's own error body is logged server-side and never returned.", + ), + httpStatus: z + .number() + .int() + .nullable() + .describe( + "The upstream status, when there was one. Null for a transport failure.", + ), + }), + ]) + .meta({ + id: "AiTestResponse", + description: + "The probe outcome. Read `ok`, not the status code: a provider that answered and rejected the call is a 200 with `ok: false`.", + }); + +// The shared-record profile summary. Deliberately bounded: allergies, family +// history and the current lifestyle facts, and nothing else. No settings, no +// AI configuration, no encrypted free text — which is what lets it be read +// under a `profile` grant. +const profileSummaryResponse = z + .object({ + allergies: z + .array(z.object({ substance: z.string(), category: z.string() })) + .describe("Up to 25 live allergy records, newest first."), + familyHistory: z + .array(z.object({ condition: z.string(), relationship: z.string() })) + .describe("Up to 25 live family-history entries, newest first."), + facts: z + .array( + z.object({ + label: healthProfileFactKindSchema.describe( + "The fact KIND, not a display label — the client localises it.", + ), + value: z + .string() + .describe( + "The current value of that fact, e.g. `NEVER` / `FORMER` / `CURRENT` for smoking status.", + ), + }), + ) + .describe( + `Current lifestyle facts, at most one per kind (${HEALTH_PROFILE_FACT_KINDS.join(" / ")}). A kind with no current revision is OMITTED rather than reported as null, and so is one whose stored value could not be decrypted — the list is what is known, and absence is not distinguishable from unreadable here.`, + ), + }) + .meta({ + id: "ProfileSummary", + description: + "The bounded profile view a record viewer gets: allergies, family history and current lifestyle facts. Structurally incapable of becoming a Settings or AI surface.", + }); + export const profilePaths: NonNullable = { + "/api/profile/summary": { + get: { + tags: ["Records"], + summary: "Read the bounded shared-record profile summary", + description: + "The short profile a record viewer needs, and only that: allergies, family history, and the current lifestyle facts. It carries no settings, no AI configuration and no encrypted free text, which is why it can be served on a `profile` grant while the full profile cannot.\n\nRead-only, and delegable: under a switched session or an account selector the payload is the RECORD's, not the caller's.", + responses: { + ...recordRefusal(), + "200": { + description: "The record's profile summary.", + content: { + "application/json": { + schema: dataEnvelope( + profileSummaryResponse, + "ProfileSummaryEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/ai/test": { + post: { + tags: ["Auth"], + summary: "Probe the AI provider connection", + description: + "Sends a tiny fixed prompt to the resolved provider and reports whether it answered. The body is OPTIONAL: with no body the saved configuration is tested; with one, the unsaved selection is tested without touching the user row, so the settings surface can verify a credential before persisting it. Plaintext keys sent this way are never stored.\n\n**This route never returns 5xx, and that is deliberate.** A 5xx from the origin is rewritten to an HTML error page by a reverse proxy or CDN, and the client's `res.json()` then dies on ` = { ...stdResponses, }, }, + patch: { + tags: ["Auth"], + summary: "Update the calling user's AI-provider configuration", + description: + "Partial update — an omitted key is left untouched, an explicit `null` (or an empty string, on the string fields) clears the column. Key material is write-only: it is encrypted at rest on the way in and the read never gives it back, only presence plus a four-character preview.\n\nOne implicit write is worth knowing about: switching `provider` to anything other than LOCAL, without naming `baseUrl` in the same request, CLEARS the stored base URL. The column is shared across providers, so leaving it would send a cloud key to a host that was configured for a self-hosted backend.\n\nA base URL — for LOCAL or for the OpenAI-compatible gateway — runs the SSRF floor: a private or internal host is refused unless the operator allowlisted that exact hostname on this instance.\n\nThe response is a bare `{ updated: true }` acknowledgement. It does not echo the resolved configuration, so a client that needs the new state re-reads the GET.\n\nUnlike its neighbours this route does not run a Zod parse: it inspects the body field by field, so a key of the WRONG TYPE is skipped in silence rather than refused, and a body of nothing but such keys comes back as 422 'No valid fields' without naming any of them.", + requestBody: { + required: true, + content: { "application/json": { schema: aiProviderPatchRequest } }, + }, + responses: { + "200": { + description: + "Saved. The body is the fixed acknowledgement, not the resolved configuration.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ updated: z.literal(true) }), + "PatchAiProviderResponse", + ), + }, + }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "`Content-Type` is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "Nothing was written. Either no recognised field was present ('No valid fields'), or `provider` named a value outside the five, or `responseTimeoutSeconds` was not an integer in 10–600, or a base URL pointed at an internal or private host the operator has not allowlisted. Single-message — there is no per-issue list on this route.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, }, "/api/auth/me/coach-prefs": { get: { diff --git a/src/lib/validations/ai-provider.ts b/src/lib/validations/ai-provider.ts new file mode 100644 index 000000000..7126a398b --- /dev/null +++ b/src/lib/validations/ai-provider.ts @@ -0,0 +1,43 @@ +/** + * Request schema for `POST /api/ai/test` — the AI connection probe. + * + * Lives outside the route file so the OpenAPI registry can import it (a route + * module may only export handlers plus the Next.js route config), which keeps + * the published contract and the runtime parser one object. + * + * The body carries UNSAVED credentials on purpose: the settings surface tests + * a selection before it is persisted. Nothing here is written to the user row + * — `resolveProviderForTest` layers the override over the stored config for + * the duration of the one probe and drops it. + */ +import { z } from "zod/v4"; + +/** The five provider kinds a user may select. */ +export const AI_PROVIDER_KINDS = [ + "OPENAI", + "ANTHROPIC", + "LOCAL", + "OPENAI_COMPATIBLE", + "CHATGPT_OAUTH", +] as const; + +export const aiProviderKindSchema = z.enum(AI_PROVIDER_KINDS); + +export const aiTestOverrideSchema = z + .object({ + provider: aiProviderKindSchema.optional().nullable(), + model: z.string().min(1).max(120).optional().nullable(), + baseUrl: z.string().url().max(2048).optional().nullable(), + anthropicKey: z.string().min(1).max(500).optional().nullable(), + localKey: z.string().min(1).max(500).optional().nullable(), + openaiKey: z.string().min(1).max(500).optional().nullable(), + // The OpenAI-compatible gateway's own fields, kept separate from + // `baseUrl` / `openaiKey` so testing an unsaved gateway config can never + // reach the pinned OpenAI arm and vice versa. + compatBaseUrl: z.string().url().max(2048).optional().nullable(), + compatKey: z.string().min(1).max(500).optional().nullable(), + compatModel: z.string().min(1).max(120).optional().nullable(), + }) + .strict(); + +export type AiTestOverrideInput = z.infer; diff --git a/src/lib/validations/onboarding.ts b/src/lib/validations/onboarding.ts new file mode 100644 index 000000000..41ad776a8 --- /dev/null +++ b/src/lib/validations/onboarding.ts @@ -0,0 +1,56 @@ +/** + * Request schemas for the two onboarding write endpoints. + * + * They live outside the route files for the same reason + * `src/lib/validations/about-me.ts` does: a route module may only export + * handlers plus the Next.js route config, so the OpenAPI registry cannot + * import a schema declared inside one. Keeping the shapes here lets the + * published contract and the runtime parser be the same object rather than + * two hand-kept copies. + * + * Prisma-free by construction — `@/lib/onboarding/goals` is pure data — so + * the generator script can pull them in without dragging the server graph + * along. + */ +import { z } from "zod/v4"; + +import { ONBOARDING_GOAL_SLUGS } from "@/lib/onboarding/goals"; + +/** + * `POST /api/onboarding/step` — the step-by-step wizard checkpoint. + * + * `step` is the step being COMPLETED and must equal the stored step plus one; + * the route refuses anything else with 409 rather than clamping. `goals` + * rides the step-2 submit and is validated against the closed slug set, so an + * unknown slug fails the whole request instead of being silently dropped. + */ +export const onboardingStepSchema = z.object({ + step: z.number().int().min(1).max(4), + goals: z + .array(z.enum(ONBOARDING_GOAL_SLUGS)) + .max(ONBOARDING_GOAL_SLUGS.length) + .optional(), +}); + +export type OnboardingStepInput = z.infer; + +/** + * `POST /api/onboarding/complete` — the legacy single-shot completion path. + * + * Every field is optional: the endpoint's job is the completion stamp, and + * the profile fields are whatever the wizard happened to collect. A field + * that parses but is falsy (an empty `displayName`, `heightCm: 0`) is not + * written — the route only assigns truthy values. + */ +export const onboardingCompleteSchema = z.object({ + displayName: z.string().trim().min(1).max(50).optional(), + heightCm: z.number().min(50).max(300).optional(), + dateOfBirth: z.string().optional(), + // The same three values the profile schema stores. A narrower enum here + // rejected the whole onboarding submission over a field the account is + // allowed to hold, and left the value unrecordable until the person found + // the setting again afterwards. + gender: z.enum(["MALE", "FEMALE", "OTHER"]).optional(), +}); + +export type OnboardingCompleteInput = z.infer;