diff --git a/messages/de.json b/messages/de.json index 1d22c97c3..f47b95c2c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6919,7 +6919,10 @@ "restoreSkippedExtractedFact": "Dokumentbefund", "restoreSkippedFactCommitment": "Befundübernahme", "docsLink": "Dokumentation", - "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung" + "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung", + "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens", + "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation", + "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index d01087845..f335611ac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6919,7 +6919,10 @@ "restoreSkippedExtractedFact": "Document fact", "restoreSkippedFactCommitment": "Fact commitment", "docsLink": "Documentation", - "restoreSkippedPersonalRecordReference": "Personal best provenance" + "restoreSkippedPersonalRecordReference": "Personal best provenance", + "restoreSkippedEcgReference": "ECG strip provenance", + "restoreSkippedMedicationTarget": "Medication target analyte", + "restoreSkippedScheduleRevisionLink": "Schedule era succession" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 7d5de9ed8..c33bbad2d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6919,7 +6919,10 @@ "restoreSkippedExtractedFact": "Dato del documento", "restoreSkippedFactCommitment": "Registro del dato", "docsLink": "Documentación", - "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal" + "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal", + "restoreSkippedEcgReference": "Procedencia de un trazado de ECG", + "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento", + "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index c6bae9f4c..9edb08991 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6919,7 +6919,10 @@ "restoreSkippedExtractedFact": "Donnée du document", "restoreSkippedFactCommitment": "Enregistrement de la donnée", "docsLink": "Documentation", - "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel" + "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel", + "restoreSkippedEcgReference": "Provenance d'un tracé ECG", + "restoreSkippedMedicationTarget": "Analyte cible d'un médicament", + "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 72f424c33..5df1f3bab 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6919,7 +6919,10 @@ "restoreSkippedExtractedFact": "Dato del documento", "restoreSkippedFactCommitment": "Registrazione del dato", "docsLink": "Documentazione", - "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale" + "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale", + "restoreSkippedEcgReference": "Provenienza di un tracciato ECG", + "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco", + "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/pl.json b/messages/pl.json index d726cc0e8..ef3622552 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6919,7 +6919,10 @@ "restoreSkippedExtractedFact": "Fakt z dokumentu", "restoreSkippedFactCommitment": "Zapis faktu", "docsLink": "Dokumentacja", - "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego" + "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego", + "restoreSkippedEcgReference": "Pochodzenie zapisu EKG", + "restoreSkippedMedicationTarget": "Analit docelowy leku", + "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 5cd049395..4100c070d 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -314,6 +314,7 @@ function sourceClient() { // Empty for the same reason as the sections above. documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, + ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3b25022d4..f15f3bc8a 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -59,6 +59,7 @@ import { restoreRemindersData } from "@/lib/export/reminders-backup"; import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup"; import { restoreAwardsData } from "@/lib/export/awards-backup"; import { restoreEnvironmentData } from "@/lib/export/environment-backup"; +import { restoreEcgData } from "@/lib/export/ecg-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -120,6 +121,7 @@ interface RestoreResponse { userAchievements: number; environmentContexts: number; environmentTravelLocations: number; + ecgRecordings: number; }; } @@ -564,6 +566,23 @@ const handler = apiHandler( const medByName = new Map(); const restoredMedicationIds = new Set(); + // The efficacy targets cannot ride inside the medication create + // beside the schedules and the packs: `biomarkerId` is a foreign key + // into a catalogue this transaction has not restored yet. They are + // collected here against the id the drug ACTUALLY got and written in + // a second pass once the biomarkers exist; the pass itself says why + // hoisting the biomarker restore above the medications is the worse + // of the two orderings. + const pendingEfficacyTargets: Array<{ + medicationId: string; + target: (typeof payload.medications)[number]["efficacyTargets"][number]; + }> = []; + // Supersede pointers the file states and the restore cannot honour. + // Reported as JSON paths into the file rather than as ids: the file + // is where an operator has to go to see what the position meant, and + // an id would be one the restore never wrote. + const unresolvedRevisionLinks: string[] = []; + let medicationIndex = 0; for (const m of payload.medications) { const created = await tx.medication.create({ data: { @@ -769,7 +788,93 @@ const handler = apiHandler( }); restoredMedicationIds.add(created.id); if (!medByName.has(m.name)) medByName.set(m.name, created.id); + + // ── The archived schedule eras, in two passes ──────────────── + // + // An era carries `supersededByRevisionId`, the pointer from a + // corrected ARCHIVED row to the MANUAL one that replaced it. The + // column has no `@relation`, so a value addressing nothing costs + // no error — it just stops meaning anything, which is the more + // dangerous shape here, because every era consumer SKIPS a row + // that carries the pointer. Lose it and the superseded original + // goes live again beside its own correction: two overlapping eras + // for one window, and past days minted against a plan the account + // had already corrected. + // + // A portable restore mints fresh ids, so the pointer cannot travel + // as one. It travels as a POSITION in this drug's own ordered era + // list, because that list is the only thing both ends agree on: + // the builder sorts it totally (validFrom, then createdAt, then + // id) so two eras sharing an instant cannot swap places between + // two exports of the same account, and the pointer never crosses a + // drug — every route that writes it scopes the update to one + // medication — so the drug's own list is a complete address space. + // + // Pass one writes every era with the link NULL, because the row a + // link addresses is as often written after the row addressing it + // as before. Pass two patches the links once every position has an + // id. + const revisionIds: string[] = []; + for (const revision of m.scheduleRevisions) { + const row = await tx.medicationScheduleRevision.create({ + data: { + ...(revision.id ? { id: revision.id } : {}), + medicationId: created.id, + validFrom: new Date(revision.validFrom), + validUntil: new Date(revision.validUntil), + payload: toJson(revision.payload), + source: revision.source ?? "ARCHIVED", + supersededByRevisionId: null, + ...(revision.createdAt + ? { createdAt: new Date(revision.createdAt) } + : {}), + }, + select: { id: true }, + }); + revisionIds.push(row.id); + } + for (const [index, revision] of m.scheduleRevisions.entries()) { + const to = revision.supersededByIndex; + if (to === null || to === undefined) continue; + // A hand-edited file can name a position this drug does not + // have, or the era's OWN position — and a row that supersedes + // itself is skipped by every consumer, so the era would + // disappear from the timeline while still sitting in the table. + // Neither can be honoured and neither may be guessed at, so the + // link stays NULL and the position is reported. The era still + // restores: the window and the plan it held are the history, and + // the correction pointer is the smaller loss. + if ( + !Number.isInteger(to) || + to < 0 || + to >= revisionIds.length || + to === index + ) { + unresolvedRevisionLinks.push( + `medications[${medicationIndex}].scheduleRevisions[${index}].supersededByIndex=${to}`, + ); + continue; + } + await tx.medicationScheduleRevision.update({ + where: { id: revisionIds[index] }, + data: { supersededByRevisionId: revisionIds[to] }, + }); + } + + for (const target of m.efficacyTargets) { + pendingEfficacyTargets.push({ + medicationId: created.id, + target, + }); + } + medicationIndex += 1; } + recordUnknownKeys( + skips, + "scheduleRevisionLink", + [...new Set(unresolvedRevisionLinks)], + unresolvedRevisionLinks, + ); if (payload.intakeEvents.length > 0) { // An event whose medication did not come back used to be mapped to @@ -1228,6 +1333,65 @@ const handler = apiHandler( // `committedRecordId` names the lab result it was committed to, and // that reference is resolved against the rows this loop writes. const restoredLabResultIds = new Set(); + // What each drug was supposed to move, written now that both ends + // exist. The drugs came back several hundred lines up and the + // analytes only just now, and one of those two had to move for a + // target to reach the database at all. + // + // The second pass wins over hoisting the biomarker restore above the + // medications, for two reasons. `biomarkerByName` and + // `restoredBiomarkerIds` are read by the lab-result loop directly + // below, so moving the block would separate a map from its only + // other consumer and leave a future edit free to re-order them back + // without anything noticing. And the direction of the dependency + // would read backwards: a pinned target is a leaf of the medication + // tree, and letting a leaf dictate where the clinical record is + // rebuilt inverts what the file is about. A second pass states the + // constraint where it applies instead of hiding it in a line order. + // + // A named analyte the restore did not put back cannot be written: + // `biomarkerId` is a real foreign key, so a dangling value would + // fail the constraint and roll the entire account back over one + // override. The row is dropped and NAMED. Dropping rather than + // nulling is deliberate here: the override IS the reference, and a + // row with neither arm set is what the resolver already reads as "no + // override", so keeping it would restore something that means + // nothing while still claiming the primary slot. A target that + // carried no name in the first place is a genuine live state (the + // analyte was deleted before the export) and comes back as written. + const unresolvedTargetAnalytes: string[] = []; + const writableTargets = pendingEfficacyTargets.filter((pending) => { + const name = pending.target.biomarkerName; + if (!name || biomarkerByName.has(name)) return true; + unresolvedTargetAnalytes.push(name); + return false; + }); + if (writableTargets.length > 0) { + await tx.medicationEfficacyTarget.createMany({ + data: writableTargets.map(({ medicationId, target }) => ({ + ...(target.id ? { id: target.id } : {}), + medicationId, + measurementType: target.measurementType ?? null, + biomarkerId: target.biomarkerName + ? (biomarkerByName.get(target.biomarkerName) ?? null) + : null, + primary: target.primary ?? true, + ...(target.createdAt + ? { createdAt: new Date(target.createdAt) } + : {}), + ...(target.updatedAt + ? { updatedAt: new Date(target.updatedAt) } + : {}), + })), + }); + } + recordUnknownKeys( + skips, + "medicationTarget", + [...new Set(unresolvedTargetAnalytes)], + unresolvedTargetAnalytes, + ); + for (const lab of payload.labResults) { const biomarkerId = lab.biomarkerId !== undefined @@ -1678,6 +1842,23 @@ const handler = apiHandler( skips, ); + // The ECG strips. AFTER the measurements on purpose: a recording + // carries a `measurementId` that is a real foreign key against the + // EVENT row it was filed with, so a reference written before that + // row exists does not merely dangle — it violates the constraint + // and rolls the whole restore back. The id set is the one the + // measurement branch actually wrote, threaded in rather than + // re-queried so the function stays a pure reader of this + // transaction. Both ends of this section live in + // `src/lib/export/ecg-backup.ts`. + const ecgCleared = await restoreEcgData( + tx, + ownerId, + payload, + new Set(stableRows.map((row) => row.id)), + skips, + ); + // The per-day readings and the location periods that explain them. // Neither references anything but the account, so this section has // no ordering constraint against any other and sits here beside the @@ -1742,6 +1923,7 @@ const handler = apiHandler( environmentContexts: environmentCleared.environmentContexts, environmentTravelLocations: environmentCleared.environmentTravelLocations, + ecgRecordings: ecgCleared.ecgRecordings, }; return { cleared, skipped: summarizeRestoreSkips(skips) }; }, 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/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 18c2f8552..fb423dea4 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -450,6 +450,52 @@ describe("GET /api/analytics", () => { // slice critical path). }); +/** + * `?slice=` is a closed set, and the cost of it not being one was the point. + * + * The route compared the raw string against `"summaries"` and fell through to + * the DEFAULT body on anything else, so `?slice=summary` answered 200 after + * running the heaviest chain on the surface. A client could sit on that typo + * indefinitely and see nothing but a slow, correct-looking response — the + * failure mode was invisible and expensive at the same time. + */ +describe("GET /api/analytics — the slice parameter is a closed set", () => { + const call = (url: string) => + (GET as unknown as (request: Request) => Promise)( + new Request(url), + ); + + it.each(["summary", "SUMMARIES", "default", ""])( + "refuses ?slice=%s rather than serving the expensive default body", + async (value) => { + const res = await call( + `http://localhost/api/analytics?slice=${encodeURIComponent(value)}`, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + meta?: { errorCode?: string }; + details?: { issues?: Array<{ path: string }> }; + }; + expect(body.meta?.errorCode).toBe("analytics.invalid_query"); + expect(body.details?.issues?.map((i) => i.path)).toContain("slice"); + }, + ); + + it("still serves the default body when the parameter is absent", async () => { + const res = await call("http://localhost/api/analytics"); + expect(res.status).toBe(200); + }); + + it("still serves the slim body for the one legal value", async () => { + const res = await call("http://localhost/api/analytics?slice=summaries"); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + // The slim slice is recognisable by what it does NOT carry. + expect(body.data).not.toHaveProperty("healthScore"); + expect(body.data).toHaveProperty("summaries"); + }); +}); + /** * v1.34.5 — the blood-pressure score's basis has to ARRIVE, not merely exist. * diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index ea430bc31..4af449575 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -1,7 +1,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { analyticsQuerySchema } from "@/lib/validations/analytics"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; @@ -46,13 +47,17 @@ export const dynamic = "force-dynamic"; * tests instantiate `new Request("http://localhost/api/analytics")` * and cast through). Falls back to the raw `URL(request.url)` parse * so the slim slice branch is reachable from both call shapes. + * + * Returns the object the query schema parses: an absent parameter is an + * absent KEY, not an empty string, so `slice` stays optional rather than + * failing the enum on every unparameterised call. */ -function readSliceParam(request: Request | undefined): string | null { - if (!request) return null; +function readQueryParams(request: Request | undefined): Record { + if (!request) return {}; try { - return new URL(request.url).searchParams.get("slice"); + return Object.fromEntries(new URL(request.url).searchParams); } catch { - return null; + return {}; } } @@ -60,6 +65,21 @@ export const GET = apiHandler(async (request?: Request) => { const { user } = await requireAuth(); annotate({ action: { name: "analytics.get" } }); + // A `slice` outside the closed set is refused rather than served the + // default body. Falling through was the expensive failure: `?slice=summary` + // ran the full 30-query chain and answered 200, so a client could sit on a + // typo indefinitely while paying for the heaviest read on the surface. + const parsedQuery = analyticsQuerySchema.safeParse(readQueryParams(request)); + if (!parsedQuery.success) { + annotate({ + action: { name: "analytics.get" }, + meta: { outcome: "invalid_query" }, + }); + return returnAllZodIssues(parsedQuery.error, 422, { + errorCode: "analytics.invalid_query", + }); + } + // v1.4.33 C1 — slim summaries slice for the dashboard tile strip. // The default path runs 30+ chunked findMany reads to feed // `summarize()` per type; under `?slice=summaries` the route @@ -72,7 +92,7 @@ export const GET = apiHandler(async (request?: Request) => { // behind a hover-prefetch from the Coach drawer + the correlation // tile's `` boundary. Consumers that need only the headline // tile values land on this branch and skip the heavy chain. - if (readSliceParam(request) === "summaries") { + if (parsedQuery.data.slice === "summaries") { // v1.4.34 IW-G — read-through the analytics cache keyed on // (userId, slice). The slim slice is the dashboard tile strip's hot // path; multiple dashboard mounts inside a 60-second TTL all hit a diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 2d5ef7f7c..f29671ba1 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -60,6 +60,7 @@ vi.mock("@/lib/db", () => ({ // Empty for the same reason as the sections above. documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, + ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -151,6 +152,7 @@ beforeEach(() => { vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never); vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never); vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never); vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue( diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 40169cfe2..329405629 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -63,6 +63,7 @@ vi.mock("@/lib/db", () => ({ // What a document was filed against, and what was read out of it. documentConditionLink: { findMany: vi.fn() }, extractedFact: { findMany: vi.fn() }, + ecgRecording: { findMany: vi.fn() }, }, })); @@ -174,6 +175,7 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); }); describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 99b2e7139..f24aef437 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -58,6 +58,7 @@ vi.mock("@/lib/db", () => ({ // Empty for the same reason as the sections above. documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, + ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index cee53c4a1..43cba330c 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -32,6 +32,7 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` })); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), + checkAuthSurfaceRateLimit: vi.fn(), rateLimitHeaders: () => ({}), })); vi.mock("@/lib/app-settings", () => ({ @@ -62,16 +63,19 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; -import { checkRateLimit } from "@/lib/rate-limit"; +import { checkAuthSurfaceRateLimit, checkRateLimit } from "@/lib/rate-limit"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; -function postReq(body: unknown): NextRequest { +function postReq( + body: unknown, + bearer: string | null = "hlk_test_token", +): NextRequest { return new NextRequest("http://localhost/api/ingest/medication", { method: "POST", headers: { "content-type": "application/json", - authorization: "Bearer hlk_test_token", + ...(bearer === null ? {} : { authorization: `Bearer ${bearer}` }), }, body: JSON.stringify(body), }); @@ -80,6 +84,10 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + ip: "203.0.113.7", + } as never); vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "tok-1", @@ -190,6 +198,10 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. function wireHappyPath() { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + ip: "203.0.113.7", + } as never); vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ id: "tok-1", @@ -433,3 +445,136 @@ describe("POST /api/ingest/medication — idempotency-key stability floor", () = expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); }); }); + +/** + * A bridge is identified by its TOKEN, not by the address it dials from. + * + * The bucket used to be `ingest:${ip}`, so a household running a chat bridge, + * a button and a home-automation rule — the normal deployment for this route, + * not an edge case — put all three in one 60/min cell and let the chattiest + * of them starve the other two. What makes it a defect rather than a + * trade-off is that the route already resolves a per-caller identity two + * lines further down and threw it away. + * + * The fallback matters as much as the fix: a request that resolves no token + * has no identity but its address, and must stay capped on it. Keying the + * failure path on anything shared — or on nothing — would hand an + * unauthenticated flood a free pass, or collapse every anonymous caller in + * the world into one cell, which is worse than what it replaces. + */ +describe("POST /api/ingest/medication — the rate-limit bucket", () => { + it("keys the bucket on the token, not on the client IP", async () => { + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never); + await POST( + postReq({ medicationName: "Metformin", idempotencyKey: "k-stable-001" }), + ); + + expect(vi.mocked(checkRateLimit)).toHaveBeenCalledTimes(1); + const [key, limit, windowMs] = vi.mocked(checkRateLimit).mock.calls[0]; + expect(key).toBe("ingest:token:tok-1"); + expect(limit).toBe(60); + expect(windowMs).toBe(60_000); + // The address must not appear in the key at all — that is the whole + // point. A NAT'd sibling on the same address gets its own cell. + expect(key).not.toContain("203.0.113"); + }); + + it("gives two tokens on one address two independent buckets", async () => { + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.apiToken.findUnique) + .mockResolvedValueOnce({ + id: "tok-bridge", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + lastUsedAt: null, + } as never) + .mockResolvedValueOnce({ + id: "tok-button", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + lastUsedAt: null, + } as never); + + await POST(postReq({ medicationName: "A", idempotencyKey: "k-aaaa-001" })); + await POST(postReq({ medicationName: "B", idempotencyKey: "k-bbbb-001" })); + + const keys = vi.mocked(checkRateLimit).mock.calls.map(([k]) => k); + expect(keys).toEqual([ + "ingest:token:tok-bridge", + "ingest:token:tok-button", + ]); + expect(new Set(keys).size).toBe(2); + }); + + it("refuses an exhausted token bucket with the standard envelope", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + } as never); + + const res = await POST( + postReq({ medicationName: "Metformin", idempotencyKey: "k-stable-002" }), + ); + expect(res.status).toBe(429); + const body = (await res.json()) as { + data: null; + error: string; + meta?: { errorCode?: string }; + }; + expect(body.data).toBeNull(); + // The 429 was hand-rolled through `NextResponse.json` and carried no + // `meta`, so a client branching on `errorCode` could not see it. + expect(body.meta?.errorCode).toBe("ingest.rate_limited"); + expect(prisma.medication.findFirst).not.toHaveBeenCalled(); + }); + + it("falls back to the per-IP bucket when no token is presented", async () => { + const res = await POST( + postReq( + { medicationName: "Metformin", idempotencyKey: "k-anon-0001" }, + null, + ), + ); + expect(res.status).toBe(401); + // The anonymous path must still be capped, and on the address — the only + // identity such a request has. + expect(vi.mocked(checkAuthSurfaceRateLimit)).toHaveBeenCalledTimes(1); + const [, prefix, limit, windowMs] = vi.mocked(checkAuthSurfaceRateLimit) + .mock.calls[0]; + expect(prefix).toBe("ingest:ip"); + expect(limit).toBe(60); + expect(windowMs).toBe(60_000); + // No token resolved, so no token bucket was charged. + expect(vi.mocked(checkRateLimit)).not.toHaveBeenCalled(); + }); + + it("falls back to the per-IP bucket when the token does not resolve", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never); + const res = await POST( + postReq({ medicationName: "Metformin", idempotencyKey: "k-bogus-001" }), + ); + expect(res.status).toBe(401); + expect(vi.mocked(checkAuthSurfaceRateLimit)).toHaveBeenCalledTimes(1); + expect(vi.mocked(checkRateLimit)).not.toHaveBeenCalled(); + }); + + it("caps an invalid-token flood before it can answer 401 forever", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + ip: "203.0.113.7", + } as never); + + const res = await POST( + postReq({ medicationName: "Metformin", idempotencyKey: "k-flood-001" }), + ); + expect(res.status).toBe(429); + }); +}); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 4e1845184..2d45fc4bd 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -11,7 +11,12 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { hashToken } from "@/lib/auth/hmac"; -import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + checkAuthSurfaceRateLimit, + checkRateLimit, + rateLimitHeaders, + type RateLimitResult, +} from "@/lib/rate-limit"; import { externalIntakeSchema } from "@/lib/validations/medication"; import { unstableExternalIdMeta, @@ -26,13 +31,49 @@ import { import { consumeForIntake } from "@/lib/medications/inventory/consumption"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; + +const RATE_LIMIT = 60; +const RATE_WINDOW_MS = 60 * 1000; + +/** The 429, with the standard headers and the standard error envelope. */ +function tooManyRequests(rl: RateLimitResult) { + const response = apiError("Rate limit exceeded", 429, { + errorCode: "ingest.rate_limited", + }); + for (const [key, value] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(key, value); + } + return response; +} + +/** + * Refuse a caller that presented no usable token, capped on the way out. + * + * The bucket is the caller's ADDRESS, because that is the only identity an + * unauthenticated request has. `checkAuthSurfaceRateLimit` is the shared + * helper for exactly this shape: it keys on the trusted-proxy IP normally, + * and when the proxy chain does not match the configured hop count it routes + * every anonymous caller into one tight global bucket instead of the + * free-for-all that a shared `"unknown"` cell would be. + */ +async function refuseUnauthenticated(request: NextRequest, message: string) { + const rl = await checkAuthSurfaceRateLimit( + request, + "ingest:ip", + RATE_LIMIT, + RATE_WINDOW_MS, + ); + if (!rl.allowed) return tooManyRequests(rl); + return apiError(message, 401); +} /** * External medication ingest endpoint. * Auth: Bearer token (hashed and looked up in api_tokens table). * Idempotent via idempotencyKey. - * Rate limit: 60 requests per minute per IP. + * Rate limit: 60 requests per minute per TOKEN (per client IP for a request + * that presents no valid one). */ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "ingest.medication" } }); @@ -43,19 +84,10 @@ export const POST = apiHandler(async (request: NextRequest) => { const ip = getClientIp(request); - // Rate limiting: 60 requests per minute per IP - const rl = await checkRateLimit(`ingest:${ip}`, 60, 60 * 1000); - if (!rl.allowed) { - return NextResponse.json( - { data: null, error: "Rate limit exceeded" }, - { status: 429, headers: rateLimitHeaders(rl) }, - ); - } - // Extract bearer token const authHeader = request.headers.get("authorization"); if (!authHeader?.startsWith("Bearer ")) { - return apiError("Authorization header required", 401); + return refuseUnauthenticated(request, "Authorization header required"); } const token = authHeader.slice(7); @@ -74,13 +106,31 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (!apiToken || apiToken.revoked) { - return apiError("Invalid or revoked token", 401); + return refuseUnauthenticated(request, "Invalid or revoked token"); } if (apiToken.expiresAt && apiToken.expiresAt < new Date()) { - return apiError("Token expired", 401); + return refuseUnauthenticated(request, "Token expired"); } + // A bridge is identified by its TOKEN, not by the address it dials from. + // The bucket used to be `ingest:${ip}`, so every integration behind one + // NAT shared a single 60/min cell and one chatty bridge starved the rest — + // and a household running a chat bridge, a button and a home-automation + // rule is the normal deployment here, not an edge case. + // + // The identity is only known after the lookup, so the lookup now runs + // before the limiter: one indexed single-row read ahead of what was + // already an upsert against `rate_limits`. A caller whose token does not + // resolve is still capped on the per-IP bucket it was always capped on, + // one step earlier than it reaches anything expensive. + const rl = await checkRateLimit( + `ingest:token:${apiToken.id}`, + RATE_LIMIT, + RATE_WINDOW_MS, + ); + if (!rl.allowed) return tooManyRequests(rl); + if ( !apiToken.permissions.includes("*") && !apiToken.permissions.includes("medication:ingest") 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/app/api/personal-records/__tests__/route.test.ts b/src/app/api/personal-records/__tests__/route.test.ts index 60f6b4932..30ce136d4 100644 --- a/src/app/api/personal-records/__tests__/route.test.ts +++ b/src/app/api/personal-records/__tests__/route.test.ts @@ -82,17 +82,24 @@ describe("GET /api/personal-records", () => { }); }); - it("drops an unknown metricType silently (loose-typed filter contract)", async () => { + // Was "drops an unknown metricType silently (loose-typed filter contract)". + // That test pinned the defect rather than the contract: dropping the filter + // does not degrade gracefully, it WIDENS the read to every metric, so a + // typo returned more of the record than the caller asked for and the 200 + // said nothing about it. + it("refuses an unknown metricType instead of widening the read", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await GET( req("http://localhost/api/personal-records?metricType=BOGUS"), ); - expect(res.status).toBe(200); - expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ - where: { userId: "user-1" }, - orderBy: { achievedAt: "desc" }, - take: 100, - }); + expect(res.status).toBe(422); + const body = (await res.json()) as { + meta?: { errorCode?: string }; + details?: { issues?: Array<{ path: string }> }; + }; + expect(body.meta?.errorCode).toBe("personal_records.invalid_query"); + expect(body.details?.issues?.map((i) => i.path)).toContain("metricType"); + expect(prisma.personalRecord.findMany).not.toHaveBeenCalled(); }); it("honours an explicit ?limit param", async () => { @@ -108,10 +115,10 @@ describe("GET /api/personal-records", () => { }); }); - it("clamps ?limit to the project-wide ceiling of 500", async () => { + it("serves the ceiling itself", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await GET( - req("http://localhost/api/personal-records?limit=999999"), + req("http://localhost/api/personal-records?limit=500"), ); expect(res.status).toBe(200); expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ @@ -121,19 +128,36 @@ describe("GET /api/personal-records", () => { }); }); - it("falls back to default on garbage ?limit (defence-in-depth)", async () => { + // Was "clamps ?limit to the project-wide ceiling of 500". Silently serving + // 500 rows to a caller that asked for 999 999 is the same class of quiet + // substitution as the default-fallback below: the answer differs from the + // question and nothing says so. + it("refuses a ?limit above the ceiling instead of clamping", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await GET( - req("http://localhost/api/personal-records?limit=abc"), + req("http://localhost/api/personal-records?limit=999999"), ); - expect(res.status).toBe(200); - expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ - where: { userId: "user-1" }, - orderBy: { achievedAt: "desc" }, - take: 100, - }); + expect(res.status).toBe(422); + expect(prisma.personalRecord.findMany).not.toHaveBeenCalled(); }); + // Was "falls back to default on garbage ?limit (defence-in-depth)". A + // fallback that cannot be observed is not defence, it is a typo the caller + // never learns about. + it.each(["abc", "-1", "0", "2.5"])( + "refuses a garbage ?limit=%s", + async (value) => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET( + req(`http://localhost/api/personal-records?limit=${value}`), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("personal_records.invalid_query"); + expect(prisma.personalRecord.findMany).not.toHaveBeenCalled(); + }, + ); + it("seeded record round-trips through the response", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const fakeRecord = { diff --git a/src/app/api/personal-records/route.ts b/src/app/api/personal-records/route.ts index 0900e3cf7..6b7793b1b 100644 --- a/src/app/api/personal-records/route.ts +++ b/src/app/api/personal-records/route.ts @@ -7,10 +7,18 @@ * v1.5 iOS-Swift app can build its query path against a stable * contract from day one. * - * Query params: + * Query params (validated against `listPersonalRecordsSchema`; a bad value + * is a 422, not a shrug): * - metricType: optional MeasurementType filter (e.g. ?metricType=VO2_MAX) * - limit: optional pagination cap (default 100, max 500) * + * Both were parsed defensively until this release — an unknown `metricType` + * was dropped and a garbage `limit` reverted to the default. That read as + * robustness and behaved as silence, and in one direction it was worse than + * silence: dropping the filter WIDENS the read to every metric, so a typo + * returned more rows than the caller asked for and nothing said so. The 500 + * ceiling refuses rather than clamps, for the same reason. + * * Response envelope (matches the project-wide `apiSuccess` contract): * { data: PersonalRecord[], error: null } */ @@ -18,49 +26,31 @@ import { prisma } from "@/lib/db"; import type { NextRequest } from "next/server"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess } from "@/lib/api-response"; -import { measurementTypeEnum } from "@/lib/validations/measurement"; -import type { MeasurementType } from "@/generated/prisma/client"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { listPersonalRecordsSchema } from "@/lib/validations/measurement"; export const dynamic = "force-dynamic"; -// v1.4.25 W10 reconcile (Sr-M4) — pagination bounds. Matches the -// `listMeasurementsSchema` ceiling (max 500) so every ingest-and-read -// endpoint on the route surface shares the same upper bound. Today -// the worker hasn't populated any rows so even an uncapped findMany is -// harmless, but a power user with multi-year Apple Health history could -// accumulate 50+ PRs per metric × 14 PR-trackable metrics once the -// worker lands. Add the cap now so the v1.5 iOS app's query path is -// bounded from day one. -const DEFAULT_LIMIT = 100; -const MAX_LIMIT = 500; - export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "measurements"); annotate({ action: { name: "personalRecords.list" } }); - const { searchParams } = new URL(request.url); - const metricTypeParam = searchParams.get("metricType"); - const limitParam = searchParams.get("limit"); - - // Defensive parse — drop unknown values rather than 400 so the - // caller's loosely-typed filter doesn't take the page down. - const metricType: MeasurementType | null = - metricTypeParam && measurementTypeEnum.safeParse(metricTypeParam).success - ? (metricTypeParam as MeasurementType) - : null; - - // Limit clamp: parse → clamp [1, MAX_LIMIT] → default fallback. A - // garbage value (`?limit=abc`, `?limit=-1`, `?limit=999999`) silently - // clamps to the default rather than 400-ing — same defence-in-depth - // stance the metricType parse takes. - let limit = DEFAULT_LIMIT; - if (limitParam != null) { - const parsed = Number(limitParam); - if (Number.isInteger(parsed) && parsed > 0) { - limit = Math.min(parsed, MAX_LIMIT); - } + const parsed = listPersonalRecordsSchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ); + if (!parsed.success) { + annotate({ + action: { name: "personalRecords.list" }, + meta: { + outcome: "validation_failed", + issue_count: parsed.error.issues.length, + }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "personal_records.invalid_query", + }); } + const { metricType, limit } = parsed.data; const records = await prisma.personalRecord.findMany({ where: { diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index 62019f04d..a1421a944 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -178,10 +178,22 @@ describe("PATCH /api/user/ai-provider — v1.22 fields", () => { ); }); + // Was `.rejects.toThrow(/between 10 and 600/)`. The route threw an + // HttpError for this one field while every other bad value was skipped in + // silence; it runs one Zod parse now, so the refusal is the standard + // multi-issue 422 that every sibling route answers with. it("rejects an out-of-range response timeout", async () => { - await expect( - patch(patchRequest({ responseTimeoutSeconds: 5 })), - ).rejects.toThrow(/between 10 and 600/); + const res = await patch(patchRequest({ responseTimeoutSeconds: 5 })); + expect(res.status).toBe(422); + const body = (await res.json()) as { + meta?: { errorCode?: string }; + details?: { issues?: Array<{ path: string; message?: string }> }; + }; + expect(body.meta?.errorCode).toBe("ai_provider.invalid"); + const issue = body.details?.issues?.find( + (i) => i.path === "responseTimeoutSeconds", + ); + expect(issue?.message).toMatch(/between 10 and 600/); expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); }); @@ -268,6 +280,98 @@ describe("PATCH /api/user/ai-provider — the gateway's own columns (#470)", () }); }); +/** + * A wrongly-typed field must be refused, not skipped. + * + * The route inspected the body key by key with `typeof` guards. A value of + * the wrong type failed every guard and simply produced no entry in the + * update object, so the write went ahead WITHOUT it and answered 200 — the + * caller was told the save succeeded while the field they set was discarded. + * A body of nothing but such keys fell through to "No valid fields", naming + * none of them, which is the same silence with a worse message. + */ +describe("PATCH /api/user/ai-provider — wrongly-typed fields are named", () => { + const patch = PATCH as (req: Request) => Promise; + + it.each([ + ["model", 42], + ["baseUrl", true], + ["compatModel", { nested: "object" }], + ["anthropicKey", 12345], + ["openaiKey", ["array"]], + ["responseTimeoutSeconds", "120"], + ])("refuses a %s of the wrong type and names its path", async (key, bad) => { + const res = await patch(patchRequest({ [key]: bad })); + expect(res.status).toBe(422); + const body = (await res.json()) as { + meta?: { errorCode?: string }; + details?: { issues?: Array<{ path: string }> }; + }; + expect(body.meta?.errorCode).toBe("ai_provider.invalid"); + expect(body.details?.issues?.map((i) => i.path)).toContain(key); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("refuses the whole write when one field of several is wrongly typed", async () => { + vi.mocked(isPublicUrl).mockReturnValue(true); + const res = await patch( + patchRequest({ provider: "OPENAI", model: 42, openaiKey: "sk-real" }), + ); + expect(res.status).toBe(422); + // Partial application would have persisted the provider and dropped the + // model, leaving the account in a state the caller never asked for. + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("refuses an unrecognised provider and names its path", async () => { + const res = await patch(patchRequest({ provider: "GEMINI" })); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details?: { issues?: Array<{ path: string }> }; + }; + expect(body.details?.issues?.map((i) => i.path)).toContain("provider"); + }); + + it("never echoes a rejected credential back in the error body", async () => { + const res = await patch( + patchRequest({ anthropicKey: 12345, openaiKey: "sk-super-secret" }), + ); + expect(res.status).toBe(422); + expect(JSON.stringify(await res.json())).not.toContain("sk-super-secret"); + }); + + it("still answers 'No valid fields' for a body of nothing it knows", async () => { + const res = await patch(patchRequest({ somethingElse: "value" })); + expect(res.status).toBe(422); + const body = (await res.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(body.error).toBe("No valid fields"); + expect(body.meta?.errorCode).toBe("ai_provider.no_fields"); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("keeps accepting the three-state contract on a known field", async () => { + vi.mocked(isPublicUrl).mockReturnValue(true); + await patch(patchRequest({ model: " gpt-4o ", anthropicKey: "" })); + expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + aiModel: "gpt-4o", + aiAnthropicKeyEncrypted: null, + }), + }), + ); + // An omitted key is untouched, not cleared. + const data = vi.mocked(prisma.user.update).mock.calls[0][0].data as Record< + string, + unknown + >; + expect(data).not.toHaveProperty("aiOpenaiKeyEncrypted"); + }); +}); + describe("GET /api/user/ai-provider — the gateway's fields (#470)", () => { it("reports the base URL and model, and the key as presence only", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 4fc45e32a..524a32d01 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -1,25 +1,21 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { isPublicUrl } from "@/lib/validations/notifications"; import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist"; import { encrypt, decrypt } from "@/lib/crypto"; import { resolveProviderAvailability } from "@/lib/ai/provider"; import { annotate } from "@/lib/logging/context"; +import { aiProviderPatchSchema } from "@/lib/validations/ai-provider"; export const dynamic = "force-dynamic"; -const ALLOWED = new Set([ - "OPENAI", - "ANTHROPIC", - "LOCAL", - // v1.33.1 (#470) — the OpenAI-compatible gateway (LiteLLM / OpenRouter / - // vLLM). Its base URL, key and model live in their own three columns. - "OPENAI_COMPATIBLE", - "CHATGPT_OAUTH", -]); - export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "user.ai-provider.get" } }); @@ -78,39 +74,47 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "user.ai-provider.update" } }); - const { data: body, error } = await safeJson>( - request, - { maxBytes: 64 * 1024 }, - ); + const { data: rawBody, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (error) return error; + // Every accepted field states its type in the schema, so a wrongly-typed + // value is a named issue rather than a key quietly dropped on the floor. + // The multi-issue envelope is safe to return even though this body carries + // plaintext credentials: Zod's type and range messages name the received + // TYPE, never the received value. See the note in the validations module + // before adding a `.refine()` here. + const parsed = aiProviderPatchSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "user.ai-provider.update" }, + meta: { + outcome: "validation_failed", + issue_count: parsed.error.issues.length, + }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "ai_provider.invalid", + }); + } + const body = parsed.data; + const updates: Record = {}; if (body.provider !== undefined) { - if (body.provider === null || body.provider === "") { - updates.aiProvider = null; - } else if ( - typeof body.provider === "string" && - ALLOWED.has(body.provider) - ) { - updates.aiProvider = body.provider; - } else { - throw new HttpError(422, "Invalid provider"); - } + updates.aiProvider = + body.provider === null || body.provider === "" ? null : body.provider; } if (body.model !== undefined) { - if (body.model === null || body.model === "") { - updates.aiModel = null; - } else if (typeof body.model === "string") { - updates.aiModel = body.model.trim() || null; - } + updates.aiModel = body.model === null ? null : body.model.trim() || null; } if (body.baseUrl !== undefined) { if (body.baseUrl === null || body.baseUrl === "") { updates.aiBaseUrl = null; - } else if (typeof body.baseUrl === "string") { + } else { const trimmed = body.baseUrl.trim(); // SSRF guard: by default reject private/internal hostnames so a // compromised user account cannot point the server at the cloud @@ -137,7 +141,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { if (body.compatBaseUrl !== undefined) { if (body.compatBaseUrl === null || body.compatBaseUrl === "") { updates.aiCompatBaseUrl = null; - } else if (typeof body.compatBaseUrl === "string") { + } else { const trimmed = body.compatBaseUrl.trim(); // Same SSRF floor as the Local provider: a public host always, a // private one only when the operator allowlisted it. Gateways on a LAN @@ -154,72 +158,49 @@ export const PATCH = apiHandler(async (request: NextRequest) => { } if (body.compatModel !== undefined) { - if (body.compatModel === null || body.compatModel === "") { - updates.aiCompatModel = null; - } else if (typeof body.compatModel === "string") { - updates.aiCompatModel = body.compatModel.trim() || null; - } + updates.aiCompatModel = + body.compatModel === null ? null : body.compatModel.trim() || null; } if (body.compatKey !== undefined) { - if (body.compatKey === null || body.compatKey === "") { - updates.aiCompatKeyEncrypted = null; - } else if (typeof body.compatKey === "string") { - updates.aiCompatKeyEncrypted = encrypt(body.compatKey.trim()); - } + updates.aiCompatKeyEncrypted = + body.compatKey === null || body.compatKey === "" + ? null + : encrypt(body.compatKey.trim()); } if (body.anthropicKey !== undefined) { - if (body.anthropicKey === null || body.anthropicKey === "") { - updates.aiAnthropicKeyEncrypted = null; - } else if (typeof body.anthropicKey === "string") { - updates.aiAnthropicKeyEncrypted = encrypt(body.anthropicKey.trim()); - } + updates.aiAnthropicKeyEncrypted = + body.anthropicKey === null || body.anthropicKey === "" + ? null + : encrypt(body.anthropicKey.trim()); } if (body.localKey !== undefined) { - if (body.localKey === null || body.localKey === "") { - updates.aiLocalKeyEncrypted = null; - } else if (typeof body.localKey === "string") { - updates.aiLocalKeyEncrypted = encrypt(body.localKey.trim()); - } + updates.aiLocalKeyEncrypted = + body.localKey === null || body.localKey === "" + ? null + : encrypt(body.localKey.trim()); } if (body.openaiKey !== undefined) { - if (body.openaiKey === null || body.openaiKey === "") { - updates.aiOpenaiKeyEncrypted = null; - } else if (typeof body.openaiKey === "string") { - updates.aiOpenaiKeyEncrypted = encrypt(body.openaiKey.trim()); - } + updates.aiOpenaiKeyEncrypted = + body.openaiKey === null || body.openaiKey === "" + ? null + : encrypt(body.openaiKey.trim()); } // ── v1.22 (#89) — response timeout (seconds) ────────────────── + // The 10–600 bounds are the schema's; an out-of-range value never reaches + // here. if (body.responseTimeoutSeconds !== undefined) { - if (body.responseTimeoutSeconds === null) { - updates.aiResponseTimeoutSeconds = null; - } else if ( - typeof body.responseTimeoutSeconds === "number" && - Number.isInteger(body.responseTimeoutSeconds) - ) { - // Sane bounds: 10 s floor (a stricter value just trips the timeout) and - // 600 s ceiling (a generous cap for a slow self-hosted backend). - if ( - body.responseTimeoutSeconds < 10 || - body.responseTimeoutSeconds > 600 - ) { - throw new HttpError( - 422, - "Response timeout must be between 10 and 600 seconds", - ); - } - updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds; - } else { - throw new HttpError(422, "Invalid response timeout"); - } + updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds; } if (Object.keys(updates).length === 0) { - throw new HttpError(422, "No valid fields"); + return apiError("No valid fields", 422, { + errorCode: "ai_provider.no_fields", + }); } // When the provider switches away from LOCAL, drop any stored diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 26f29a1ce..598d6aab9 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -356,6 +356,15 @@ function catalogueLabel( if (catalogue === "personalRecordReference") { return t("admin.section.backups.restoreSkippedPersonalRecordReference"); } + if (catalogue === "ecgReference") { + return t("admin.section.backups.restoreSkippedEcgReference"); + } + if (catalogue === "medicationTarget") { + return t("admin.section.backups.restoreSkippedMedicationTarget"); + } + if (catalogue === "scheduleRevisionLink") { + return t("admin.section.backups.restoreSkippedScheduleRevisionLink"); + } // Not a fallback: the chain above is exhaustive and this line is what makes // the compiler say so. A catalogue added without a label here now stops the // build instead of shipping under a label that belongs to something else, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 42de97aa7..99d035567 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -236,6 +236,41 @@ function makePrisma() { redValue: 180, redMode: "MINUTES", }, + // Two archived eras, the first corrected by the second. The + // pointer is what makes this more than two rows: the payload has + // to carry it as the SECOND era's position, not as its id. + scheduleRevisions: [ + { + id: "era-original", + validFrom: new Date("2026-01-01T00:00:00.000Z"), + validUntil: new Date("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "07:00", windowEnd: "09:00" }], + source: "ARCHIVED", + supersededByRevisionId: "era-correction", + createdAt: new Date("2026-03-01T00:00:01.000Z"), + }, + { + id: "era-correction", + validFrom: new Date("2026-03-01T00:00:00.000Z"), + validUntil: new Date("2026-05-01T00:00:00.000Z"), + payload: [{ windowStart: "08:00", windowEnd: "10:00" }], + source: "MANUAL", + supersededByRevisionId: null, + createdAt: new Date("2026-05-02T00:00:01.000Z"), + }, + ], + // One pinned efficacy target, on the lab arm. The biomarker rides + // as a NAME, the way a lab result's cross-reference does. + efficacyTargets: [ + { + id: "target-canonical", + measurementType: null, + biomarker: { name: "Ferritin" }, + primary: true, + createdAt: new Date("2026-07-01T08:00:00.000Z"), + updatedAt: new Date("2026-07-01T08:00:00.000Z"), + }, + ], inventoryEvents: [ { id: "stock-in", @@ -373,6 +408,10 @@ function makePrisma() { // Empty for the same reason as the sections above. documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, + // The ECG strips. Empty for the same reason as the sections above: + // an account whose watch has never taken one is the ordinary case the + // builder must still answer for. + ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 5afdce432..5f47cadf2 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -236,6 +236,11 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // the reason the visits comment gives. "src/lib/export/awards-backup.ts", "src/lib/export/environment-backup.ts", + // The ECG strips, same arrangement again. The recording reads through its + // OWN delegate here rather than riding the measurement it references, + // because that reference is a pointer between two independently carried + // tables and not a parent-child ride. + "src/lib/export/ecg-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -252,6 +257,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/document-filing-backup.ts", "src/lib/export/awards-backup.ts", "src/lib/export/environment-backup.ts", + "src/lib/export/ecg-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -448,6 +454,47 @@ export const TWO_ENDED_MODELS = [ // fortnight at home, days after a restore that reported success. "EnvironmentContext", "EnvironmentTravelLocation", + // How a schedule got to where it is. The register said a restored + // medication keeps today's schedule and loses the record of when the dose or + // the timing moved. Measured, it costs more than a record: the era minter + // reads past days against the schedule that was live THEN, so an account + // that came back without its eras has its own compliance history rewritten + // against a plan it was not on. + // + // `supersededByRevisionId` is the reference that made this its own problem. + // It is a bare id column with no `@relation`, so a dangling value costs no + // error and simply stops meaning anything — and every era consumer skips a + // row that carries it, which means losing the pointer puts a corrected era + // back on the timeline beside its own correction. It travels as a POSITION + // in the drug's own ordered era list, because a portable restore mints fresh + // ids and the list is the only address space both ends share. + "MedicationScheduleRevision", + // What each drug was supposed to move. The register said the drug comes + // back with no statement of what it was for, and that is exactly the row: + // one exists ONLY where the person overrode the derived ATC / name + // resolution, so it is a decision and never a computation. + // + // It travels by biomarker NAME rather than by id, the way a lab result's + // cross-reference does, because a portable restore mints a fresh catalogue + // id and `(userId, name)` is what the catalogue is unique on. The reference + // is a real foreign key, so a name the restore cannot resolve is dropped and + // reported rather than written — a dangling id here would fail the + // constraint and cost the whole account. + "MedicationEfficacyTarget", + // The ECG strips. The register said the originating device may still hold + // them, which is true for about as long as the phone beside it does — and + // is not true at all once a Withings connection is revoked, because the + // signal id stops resolving with it. + // + // `measurementId` is the reference that made this more than a copy job. It + // is a REAL foreign key against the EVENT measurement the strip was filed + // with, so a value the restore cannot resolve does not dangle quietly the + // way the Coach's `sourceConversationId` does: it violates the constraint + // and takes the whole account down with it. A portable export omits + // soft-deleted measurements, so that case is reachable rather than + // theoretical. The reference is resolved against what the restore wrote, + // nulled when it cannot be, and reported either way. + "EcgRecording", ] as const; /** One model claimed to travel both ways. */ @@ -484,12 +531,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { * rather than by what is cheapest to write. */ export const COVERAGE_PENDING: Readonly> = { - MedicationScheduleRevision: - "The history of how a schedule changed. Without it a restored medication keeps today's schedule and loses the record of when the dose or timing moved, which is the part a doctor asks about.", - MedicationEfficacyTarget: - "What a medication was supposed to move, and by how much. The drug comes back with no statement of what it was for.", - EcgRecording: - "ECG traces and their rhythm classification. The originating device may still hold them, but a self-hoster who exported and wiped has nothing to re-sync from.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: diff --git a/src/lib/export/ecg-backup.ts b/src/lib/export/ecg-backup.ts new file mode 100644 index 000000000..c21aa2682 --- /dev/null +++ b/src/lib/export/ecg-backup.ts @@ -0,0 +1,352 @@ +/** + * The ECG strips, with both backup ends in one file. + * + * Same arrangement as `reminders-backup.ts`, `visits-backup.ts` and + * `coach-backup.ts`, for the same reason: a reader asking "is this carried at + * both ends?" answers it here, and a reader who greps only the restore ROUTE + * gets a false negative because the route delegates. + * + * One model rides. Its register entry read "ECG traces and their rhythm + * classification. The originating device may still hold them, but a + * self-hoster who exported and wiped has nothing to re-sync from." That is the + * whole case: a watch keeps a strip only as long as the phone beside it does, + * and the Withings signal id stops resolving the moment the connection is + * revoked. + * + * ## The trace and the verdict are different losses + * + * A recording is three things at once: WHEN a heart was recorded, WHAT the + * device concluded about the rhythm, and the micro-volt series behind that + * conclusion. A restore that returned the row with a defaulted + * `rhythmClassification` would hand back a strip nobody has read a verdict off, + * and a restore that returned it with a defaulted `recordedAt` would hand back + * a strip that belongs to no day. Both are carried verbatim, and both are + * asserted in `tests/integration/backup-round-trip.test.ts` rather than counted. + * + * ## The waveform follows the note contract, with one exception + * + * A disaster-recovery payload carries the stored ciphertext verbatim as base64, + * because the same instance's key reads it back unchanged. A portable export + * decrypts it, because a portable export exists to be readable by the person + * who owns it, exactly as it already decrypts medication notes and mood notes. + * + * The exception is what happens when a strip cannot be decrypted at all — a row + * written under a key the instance has since dropped. The Coach substitutes a + * placeholder sentence for a turn in that state, because the other nine hundred + * turns are still the person's and a thrown error would cost them all of it. + * There is no placeholder for a waveform: `waveformEncrypted` is a required + * column, so the choice is between writing a recording that describes a strip + * nothing can draw and leaving it out. The unreadable recording is left OUT of + * a portable file and a warning is raised on the wide event, so the number of + * strips in the file is the number of strips that can be shown. A + * disaster-recovery payload never decrypts and is therefore never affected. + * + * ## The one reference that needs care + * + * `measurementId` addresses the IRREGULAR_RHYTHM_NOTIFICATION event row the + * strip was filed against, and unlike the Coach's `sourceConversationId` it is + * a REAL foreign key. A value pointing at nothing does not quietly stop meaning + * anything here: it violates the constraint and rolls the WHOLE restore back, + * which hands the operator an empty account over one dangling id. That is not + * hypothetical in this section — a portable export omits soft-deleted + * measurements, so a strip filed against a measurement the account has since + * deleted names a row the file does not carry. + * + * So the reference is resolved against the measurements the restore actually + * wrote, and a miss is NULLED and reported rather than written. Nulling rather + * than dropping is deliberate: the strip is the record, the event row is a + * cross-reference, and losing the recording to save the pointer would be the + * wrong way round. + */ +import { Buffer } from "node:buffer"; + +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { + MeasurementSource, + RhythmClassification, +} from "@/generated/prisma/client"; + +import { getEvent } from "@/lib/logging/context"; +import { + decryptWaveformFromBytes, + encryptWaveformToBytes, +} from "@/lib/withings/ecg-waveform-codec"; +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export interface EcgBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One recorded strip. */ +export interface EcgRecordingBackupEntry { + /** Present in a disaster-recovery payload; a portable file mints a fresh id + * because nothing else in the file addresses a recording. */ + id?: string; + source: MeasurementSource; + externalRecordingId: string; + /** When the strip was taken on-device. Never the export time: a recording + * without its own instant belongs to no day. */ + recordedAt: string; + /** Ciphertext as base64. Present on a disaster-recovery payload only. */ + waveformEncrypted?: string; + /** The micro-volt sample array. Present on a portable payload only. */ + waveform?: number[]; + samplingFrequency: number; + sampleCount: number; + durationSeconds: number | null; + lead: string | null; + averageHeartRate: number | null; + /** The device's own rhythm verdict. Carried verbatim — nothing can re-derive + * a conclusion a device reached about a strip. */ + rhythmClassification: RhythmClassification | null; + /** The paired EVENT measurement, remapped on the way back. */ + measurementId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface EcgBackupSection { + ecgRecordings: EcgRecordingBackupEntry[]; +} + +export interface EcgBackupCounts { + ecgRecordings: number; +} + +/** + * Every restorable `EcgRecording` scalar. + * + * Named rather than inlined so a column added to the model shows up as a diff + * here rather than as a silent omission from the file. + */ +const ECG_RECORDING_BACKUP_SELECT = { + id: true, + source: true, + externalRecordingId: true, + recordedAt: true, + waveformEncrypted: true, + samplingFrequency: true, + sampleCount: true, + durationSeconds: true, + lead: true, + averageHeartRate: true, + rhythmClassification: true, + measurementId: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.EcgRecordingSelect; + +/** + * Build the ECG slice of a user's full backup. + * + * Takes the delegate it uses rather than a whole client, matching the other + * section builders. The table carries no tombstone column, so both purposes + * read the same rows; they differ only in how the waveform travels. + */ +export async function buildEcgBackupSection( + prisma: Pick, + userId: string, + options: EcgBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + const rows = await prisma.ecgRecording.findMany({ + where: { userId }, + orderBy: { recordedAt: "asc" }, + select: ECG_RECORDING_BACKUP_SELECT, + }); + + const ecgRecordings: EcgRecordingBackupEntry[] = []; + for (const row of rows) { + const common = { + source: row.source, + externalRecordingId: row.externalRecordingId, + recordedAt: row.recordedAt.toISOString(), + samplingFrequency: row.samplingFrequency, + sampleCount: row.sampleCount, + durationSeconds: row.durationSeconds, + lead: row.lead, + averageHeartRate: row.averageHeartRate, + rhythmClassification: row.rhythmClassification, + measurementId: row.measurementId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + + if (disasterRecovery) { + ecgRecordings.push({ + id: row.id, + waveformEncrypted: Buffer.from(row.waveformEncrypted).toString( + "base64", + ), + ...common, + }); + continue; + } + + // See the file header: a strip that will not decrypt is left out rather + // than written as a recording with no trace behind it. + let waveform: number[]; + try { + waveform = decryptWaveformFromBytes(row.waveformEncrypted); + } catch (err) { + getEvent()?.addWarning( + `ECG waveform decrypt failed, recording omitted from portable export: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + continue; + } + ecgRecordings.push({ waveform, ...common }); + } + + return { ecgRecordings }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countEcgBackupSection( + section: EcgBackupSection, +): EcgBackupCounts { + return { ecgRecordings: section.ecgRecordings.length }; +} + +/** Counts the ECG restore wiped, for the audit trail. */ +export interface EcgRestoreCleared { + ecgRecordings: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: the payload schema defaults the key, so every + * caller already satisfies this, and one that stops satisfying it fails to + * compile instead of restoring nothing and reporting success. + */ +export interface EcgRestoreInput { + ecgRecordings: RestoredEcgRecording[]; +} + +/** + * What the parser hands over, which is looser than what the builder writes: an + * older file carries no ECG key at all, and every optional column arrives as + * `undefined` rather than `null`. + */ +type OptionalNullable = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredEcgRecording = Pick< + EcgRecordingBackupEntry, + | "source" + | "externalRecordingId" + | "recordedAt" + | "samplingFrequency" + | "sampleCount" + | "createdAt" + | "updatedAt" +> & + OptionalNullable< + Pick< + EcgRecordingBackupEntry, + | "id" + | "waveformEncrypted" + | "waveform" + | "durationSeconds" + | "lead" + | "averageHeartRate" + | "rhythmClassification" + | "measurementId" + > + >; + +/** + * Re-create the account's ECG recordings. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. The wipe at the top of the restore does NOT reach these: an ECG row + * hangs off the account, not off a measurement, so deleting the measurements + * only nulls its `measurementId` and leaves the strip behind. Without the + * delete here a restore would stack a second copy of every recording on top of + * the first, and the `(userId, source, recordedAt, samplingFrequency)` unique + * would fail the whole transaction on the first duplicate. + * + * MUST be called AFTER the measurements are restored: `measurementId` is a real + * foreign key against them, so running earlier would make every reference + * unresolvable — and, worse than unresolvable, would take the restore down with + * a constraint violation if it were written anyway. + * + * `measurementIds` is the set of measurements the restore actually put back, + * passed in rather than re-queried so this stays a pure function of the + * transaction it was handed. + */ +export async function restoreEcgData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: EcgRestoreInput, + measurementIds: ReadonlySet, + skips: RestoreSkipLog, +): Promise { + const cleared = await tx.ecgRecording.deleteMany({ + where: { userId: ownerId }, + }); + + const droppedMeasurementRefs: string[] = []; + for (const recording of payload.ecgRecordings) { + let measurementId = recording.measurementId ?? null; + if (measurementId && !measurementIds.has(measurementId)) { + droppedMeasurementRefs.push(measurementId); + measurementId = null; + } + await tx.ecgRecording.create({ + data: { + ...(recording.id ? { id: recording.id } : {}), + userId: ownerId, + source: recording.source, + externalRecordingId: recording.externalRecordingId, + recordedAt: new Date(recording.recordedAt), + waveformEncrypted: resolveWaveformBytes(recording), + samplingFrequency: recording.samplingFrequency, + sampleCount: recording.sampleCount, + durationSeconds: recording.durationSeconds ?? null, + lead: recording.lead ?? null, + averageHeartRate: recording.averageHeartRate ?? null, + // Verbatim, never re-derived. The verdict is what a device concluded + // looking at the signal, and this instance is not that device. + rhythmClassification: recording.rhythmClassification ?? null, + measurementId, + createdAt: new Date(recording.createdAt), + updatedAt: new Date(recording.updatedAt), + }, + }); + } + + recordUnknownKeys( + skips, + "ecgReference", + [...new Set(droppedMeasurementRefs)], + droppedMeasurementRefs, + ); + + return { ecgRecordings: cleared.count }; +} + +/** + * A strip's stored bytes, whichever end of the contract the file came from. + * + * A disaster-recovery file carries ciphertext that decodes straight back into + * the column. A portable file carries the sample array, which is encrypted on + * the way in under the TARGET instance's key — the whole point of a portable + * file being portable. + */ +function resolveWaveformBytes( + recording: RestoredEcgRecording, +): Uint8Array { + if (recording.waveformEncrypted !== undefined) { + const decoded = Buffer.from(recording.waveformEncrypted, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; + } + return encryptWaveformToBytes(recording.waveform ?? []); +} diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index a12cc5a9c..b04d6e6c6 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -90,6 +90,12 @@ import { type EnvironmentBackupCounts, type EnvironmentBackupSection, } from "@/lib/export/environment-backup"; +import { + buildEcgBackupSection, + countEcgBackupSection, + type EcgBackupCounts, + type EcgBackupSection, +} from "@/lib/export/ecg-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -111,7 +117,8 @@ export interface FullBackupCounts SensitiveBackupCounts, DocumentFilingBackupCounts, AwardsBackupCounts, - EnvironmentBackupCounts { + EnvironmentBackupCounts, + EcgBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -121,6 +128,8 @@ export interface FullBackupCounts medicationInventoryItems: number; medicationInventoryEvents: number; reminderPhaseConfigs: number; + medicationEfficacyTargets: number; + medicationScheduleRevisions: number; moodEntries: number; customMoodTagCategories: number; hiddenMoodTags: number; @@ -341,6 +350,7 @@ export async function buildFullBackupPayload( documentFiling, awards, environment, + ecg, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -417,6 +427,26 @@ export async function buildFullBackupPayload( inventoryItems: { orderBy: { createdAt: "asc" } }, inventoryEvents: { orderBy: { occurredAt: "asc" } }, phaseConfig: true, + // The archived schedule eras. Ordered TOTALLY, not just by the column + // that reads like the sort key: the position in this list is the + // identifier the supersede pointer travels as, so two eras sharing a + // `validFrom` must not be free to swap places between two exports of + // the same account. + scheduleRevisions: { + orderBy: [ + { validFrom: "asc" }, + { createdAt: "asc" }, + { id: "asc" }, + ] as const, + }, + // What the drug was supposed to move. The biomarker is read by NAME + // rather than by id, exactly as the lab results beside it are: a + // portable restore mints a fresh biomarker id, and `(userId, name)` is + // the natural key the catalogue is already unique on. + efficacyTargets: { + orderBy: { createdAt: "asc" }, + include: { biomarker: { select: { name: true } } }, + }, }, }), prisma.medicationIntakeEvent.findMany({ @@ -549,6 +579,12 @@ export async function buildFullBackupPayload( // `src/lib/export/environment-backup.ts`, and the purpose is absent for // the same reason as the awards above. buildEnvironmentBackupSection(prisma, userId), + // The ECG strips, their rhythm verdicts and their traces. Both ends live + // in `src/lib/export/ecg-backup.ts` beside each other, the same + // arrangement as the sections above and for the same reason. + buildEcgBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -588,6 +624,20 @@ export async function buildFullBackupPayload( const documentFilingSection: DocumentFilingBackupSection = documentFiling; const awardsSection: AwardsBackupSection = awards; const environmentSection: EnvironmentBackupSection = environment; + const ecgSection: EcgBackupSection = ecg; + + // Where each archived era sits in its OWN drug's ordered list. Built once + // here rather than per row, and scoped per medication because the supersede + // pointer never crosses a drug — every route that writes it scopes the + // update to the medication it belongs to. + const revisionIndexByMedication = new Map( + medications.map((m) => [ + m.id, + new Map( + m.scheduleRevisions.map((revision, index) => [revision.id, index]), + ), + ]), + ); const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -782,6 +832,56 @@ export async function buildFullBackupPayload( reason: e.reason, occurredAt: e.occurredAt.toISOString(), })), + // How the schedule got to where it is. The drug comes back on today's + // plan either way; without these the record of when the dose or the + // timing moved is gone, and the era minter reads every past day against + // the CURRENT schedule instead of the one that was live then — so a + // restored account's own compliance history quietly changes. + // + // `supersededByRevisionId` travels as a POSITION in this list rather + // than as an id. See the restore for the whole argument; the short form + // is that a portable restore mints fresh ids, so an id would point at + // nothing, and the list this array IS is the only stable thing both ends + // agree on. A pointer at a row outside this drug's own list cannot be + // expressed and is written as null — the schema allows it, no code path + // produces it, and inventing an index for it would be worse than saying + // nothing. + scheduleRevisions: m.scheduleRevisions.map((r) => ({ + ...(disasterRecovery ? { id: r.id } : {}), + validFrom: r.validFrom.toISOString(), + validUntil: r.validUntil.toISOString(), + payload: r.payload, + source: r.source, + supersededByIndex: r.supersededByRevisionId + ? (revisionIndexByMedication + .get(m.id) + ?.get(r.supersededByRevisionId) ?? null) + : null, + createdAt: r.createdAt.toISOString(), + })), + // What the drug is FOR, in the person's own words rather than the + // resolver's. A row exists only where they overrode the derived + // ATC / name inference, so it is the one durable statement of intent + // the "Wirkung" view holds — and the drug restores with no statement of + // what it was for without it. + // + // The lab arm travels as a NAME, the way a lab result's does. The id is + // fresh on a portable restore, and `Biomarker` is unique on + // `(userId, name)`, so the name is the only thing that survives the + // trip. It is resolved on the way back against the biomarkers the + // restore wrote. + efficacyTargets: m.efficacyTargets.map((t) => ({ + ...(disasterRecovery + ? { + id: t.id, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + } + : {}), + measurementType: t.measurementType, + biomarkerName: t.biomarker?.name ?? null, + primary: t.primary, + })), })), intakeEvents: intakeEvents.map((e) => ({ ...(disasterRecovery @@ -946,6 +1046,7 @@ export async function buildFullBackupPayload( ...documentFilingSection, ...awardsSection, ...environmentSection, + ...ecgSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -982,6 +1083,14 @@ export async function buildFullBackupPayload( 0, ), reminderPhaseConfigs: medications.filter((m) => m.phaseConfig).length, + medicationEfficacyTargets: medications.reduce( + (total, m) => total + m.efficacyTargets.length, + 0, + ), + medicationScheduleRevisions: medications.reduce( + (total, m) => total + m.scheduleRevisions.length, + 0, + ), moodEntries: moodEntries.length, customMoodTagCategories: customMoodTagCategories.length, hiddenMoodTags: hiddenMoodTags.length, @@ -1001,6 +1110,7 @@ export async function buildFullBackupPayload( ...countDocumentFilingBackupSection(documentFiling), ...countAwardsBackupSection(awards), ...countEnvironmentBackupSection(environment), + ...countEcgBackupSection(ecg), }, }; } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index a0731bf2f..ef8acb6c3 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -104,8 +104,35 @@ * `ON DELETE SET NULL` in the first place. The ordinary way it fails to * resolve is a portable export whose source measurement was soft-deleted, and * a legacy file whose measurements carry no ids at all. + * The tenth, `ecgReference`, names the EVENT measurement an ECG strip was + * filed against that the restore did not put back. Unlike `coachReference` it + * is a real foreign key, so writing the dangling value would not quietly stop + * meaning anything — it would violate the constraint and roll the whole + * restore back over one cross-reference. What is dropped is the POINTER: the + * strip, its instant and its rhythm verdict all restore. A portable export + * omits soft-deleted measurements, which is the ordinary way a live recording + * ends up naming one the file does not carry. * - * The eleventh, `checkupClosure`, is not about a restore at all, and it borrows + * The eleventh, `medicationTarget`, names the lab analyte a medication's + * pinned efficacy target pointed at that the restore did not put back. It is + * the one kind here where the ROW is dropped rather than the pointer, and the + * reason is what the row is: an override exists only to say "this drug is for + * that analyte", so an override with nothing on the far side states nothing. + * The resolver already treats a target with neither arm set as "no override", + * so writing one back would restore a row that means nothing and still claims + * the primary slot. The drug, its schedule and its whole history come back; + * what is lost is one statement of intent, and the operator is told which. + * + * The twelfth, `scheduleRevisionLink`, names a supersede pointer between two + * archived schedule eras that the restore could not honour. Its key is a JSON + * PATH into the file rather than an id, because the pointer travels as a + * position in the drug's own era list and there is no id on either end that + * the file and the database both know. Only a hand-edited or truncated file + * can trip it: the builder writes a position it has just computed from the + * same list. What is lost is the pointer and not the era — the window and the + * plan it held still restore. + * + * The thirteenth, `checkupClosure`, is not about a restore at all, and it borrows * this shape deliberately rather than growing a second reporting mechanism * beside it. The situation is the same one: something a write was asked to do * could not be done, the record itself survives, and the person is told which @@ -127,6 +154,9 @@ export type SkippedCatalogue = | "extractedFact" | "factCommitment" | "personalRecordReference" + | "ecgReference" + | "medicationTarget" + | "scheduleRevisionLink" | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts new file mode 100644 index 000000000..38369fb21 --- /dev/null +++ b/src/lib/openapi/routes/analytics.ts @@ -0,0 +1,548 @@ +/** + * 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, errorEnvelope, 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; omitting the parameter returns the thick default body. Any OTHER value is refused with 422 (`meta.errorCode` = `analytics.invalid_query`). It used to fall through to the default instead, which made `?slice=summary` answer 200 after running the heaviest chain on the surface — the typo was invisible and expensive at the same time.\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. Omit it for the default body. Any other value is a 422 — it is not treated as an omission.", + }, + ], + responses: { + "200": { + description: + "The analytics body. `AnalyticsSummariesSlice` for `?slice=summaries`, `AnalyticsDefaultResponse` otherwise.", + content: { + "application/json": { + schema: dataEnvelope( + z.union([analyticsDefaultResponse, analyticsSummariesSlice]), + "AnalyticsEnvelope", + ), + }, + }, + }, + ...stdResponses, + "422": { + description: + "`slice` carried a value outside the closed set. `meta.errorCode` = `analytics.invalid_query`, with the offending path under `details.issues`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/awards.ts b/src/lib/openapi/routes/awards.ts new file mode 100644 index 000000000..a442b6076 --- /dev/null +++ b/src/lib/openapi/routes/awards.ts @@ -0,0 +1,240 @@ +/** + * 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, + errorEnvelope, + 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 validated and a bad value is a 422. They were forgiving until this release, and in one direction that was worse than merely quiet: dropping an unrecognised `metricType` WIDENED the read to every metric, so a typo returned more rows than the caller asked for. The `limit` ceiling refuses rather than clamps for the same reason.\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 refused with 422 — it is not dropped, because dropping it would widen the read rather than narrow it.", + }, + { + name: "limit", + in: "query", + required: false, + schema: { type: "integer", minimum: 1, maximum: 500, default: 100 }, + description: + "Page cap, default 100, ceiling 500. A non-integer, a zero, a negative value or anything above 500 is refused with 422 rather than corrected — serving a different number of rows than were asked for is another difference nobody is told about.", + }, + ], + responses: { + ...recordRefusal(), + "200": { + description: "The record's personal bests, newest first.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(personalRecord), + "ListPersonalRecordsEnvelope", + ), + }, + }, + }, + ...stdResponses, + "422": { + description: + "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/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 2e686ffea..746e669bb 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, @@ -148,6 +153,15 @@ export const openApiPaths: NonNullable = { // therefore has to be the shared refusal sentence rather than the neighbouring // module's local wording (appended, spread order is load-bearing). ...recordSettingsPaths, + // 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..fb2cfb643 --- /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 token**. A household running a chat bridge, a button and a home-automation rule gets three independent allowances rather than three shares of one, which is what the previous per-IP bucket gave them. A request that presents no valid token falls back to a per-IP bucket with the same ceiling, so an unauthenticated flood is still capped.", + 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, and all four are charged to the per-IP bucket rather than to any token.", + 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 for this token — or, when no valid token was presented, from this client IP. `meta.errorCode` = `ingest.rate_limited`, plus the standard `X-RateLimit-Remaining` / `X-RateLimit-Reset` headers.", + 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 03230b2ae..f0c2739a4 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -29,6 +29,14 @@ import { unitPreferencePatchSchema, useCentralCodexPatchSchema, } from "@/lib/validations/user-prefs"; +import { + aiProviderPatchSchema, + aiTestOverrideSchema, +} from "@/lib/validations/ai-provider"; +import { + HEALTH_PROFILE_FACT_KINDS, + healthProfileFactKindSchema, +} from "@/lib/validations/health-profile-facts"; import { dataEnvelope, errorEnvelope, @@ -38,6 +46,7 @@ import { conflictResponse409, invalidBaseTokenResponse, deviceRevokeResponse, + recordRefusal, } from "./shared"; // v1.18.0 — module enable/disable. The PATCH request is the REAL runtime @@ -740,7 +749,167 @@ const timezonePutRequest = z "The account's timezone. The handler reads and trims the field directly rather than through a Zod schema.", }); +// The PATCH body is the runtime validator, imported rather than restated. +// It used to be described here instead, because the route hand-rolled its +// parse field by field and there was nothing to import; the route runs +// `safeParse` now, so the published shape and the parse are one object. +const aiProviderPatchRequest = aiProviderPatchSchema.meta({ + id: "AiProviderPatchRequest", + description: + "Partial update of the calling user's AI-provider configuration. An omitted key is left untouched; `null` (or an empty string, on the string fields) clears the column. At least one recognised key must be present, or the write is refused. Key material is write-only: it is encrypted on the way in and never returned. Unknown keys are ignored rather than refused — the object is deliberately not strict, so a client sending a field this server does not know yet is not broken by it.", +}); + +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\nA wrongly-typed value is refused, not skipped. Until this release the route inspected the body field by field with `typeof` guards, so a numeric `model` or a boolean `baseUrl` was dropped in silence and the write went ahead without it; a body of nothing but such keys came back as 'No valid fields' naming none of them. It runs the standard Zod parse now and answers the multi-issue 422 with the offending path.", + 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. A body that failed validation carries the multi-issue list under `details.issues` with `meta.errorCode` = `ai_provider.invalid` — a wrongly-typed field, a `provider` outside the five, or a `responseTimeoutSeconds` that is not an integer in 10–600 all land there and name their path. Two single-message cases stay outside that list because they are not shape failures: no recognised field was present at all (`meta.errorCode` = `ai_provider.no_fields`), and a base URL pointing at an internal or private host the operator has not allowlisted.", + 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..7b51bcffe --- /dev/null +++ b/src/lib/validations/ai-provider.ts @@ -0,0 +1,117 @@ +/** + * Request schemas for the AI-provider surfaces: the configuration write + * (`PATCH /api/user/ai-provider`) and the connection probe + * (`POST /api/ai/test`). + * + * They live outside the route files so the OpenAPI registry can import them (a + * route module may only export handlers plus the Next.js route config), which + * keeps the published contract and the runtime parser one object. + * + * Both bodies carry plaintext credentials, which puts one constraint on every + * rule below: a validation message must never interpolate the value it + * rejected. Zod's built-in type, enum and range messages name the received + * TYPE and never the received value, so the multi-issue 422 these feed is safe + * to return. A `.refine()` with a message that quotes its input would break + * that, and would put a key in an error body. + */ +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); + +/** + * A field the caller may set, clear, or leave alone. + * + * Three states, and the difference matters on every column here: an OMITTED + * key leaves the stored value untouched, `null` or `""` clears it, and a + * non-empty string replaces it. The empty string is not a synonym for "no + * change" anywhere on this surface — the settings forms send `null` and `""` + * interchangeably to mean "clear", and both have always cleared. + */ +const clearableString = z.string().nullable(); + +/** + * `PATCH /api/user/ai-provider` — a partial update of the provider config. + * + * The route used to inspect the body key by key with `typeof` guards, which + * meant a wrongly-typed value (a numeric `model`, a boolean `baseUrl`) was + * SKIPPED rather than refused: the write went ahead without it, and a body of + * nothing but such keys came back as "No valid fields" without naming one. + * This schema is what closes that — every accepted field now states its type, + * and a mismatch is a named issue in the standard multi-issue 422. + * + * The accepted field set is exactly what the route already accepted; this + * describes the existing contract rather than changing it. In particular the + * object is deliberately NOT `.strict()`: an unrecognised key has always been + * ignored here, and refusing one now would break a client sending a field this + * server does not know yet. Unknown keys are dropped, as before. + */ +export const aiProviderPatchSchema = z.object({ + /** + * The selected provider. `null` / `""` clears it. Unlike every other field + * this one has always REFUSED an unrecognised value rather than skipping it, + * and still does. + */ + provider: z + .union([aiProviderKindSchema, z.literal("")]) + .nullable() + .optional(), + /** Trimmed on write; whitespace-only clears, like `""`. */ + model: clearableString.optional(), + /** + * Custom base URL for the LOCAL provider. Trimmed, then run through the + * SSRF floor at the route — a private host is refused there, not here, + * because the allowlist is operator state rather than a shape rule. + */ + baseUrl: clearableString.optional(), + /** The OpenAI-compatible gateway's own base URL. Same SSRF floor. */ + compatBaseUrl: clearableString.optional(), + compatModel: clearableString.optional(), + /** Encrypted at rest on write; never echoed back by the read. */ + compatKey: clearableString.optional(), + anthropicKey: clearableString.optional(), + localKey: clearableString.optional(), + openaiKey: clearableString.optional(), + /** + * Per-user AI response timeout in seconds. `null` restores the built-in + * default. The 10 s floor is below what any real backend beats and the + * 600 s ceiling is generous for a slow self-hosted one; outside that range + * the value is refused rather than clamped, so a typo is visible. + */ + responseTimeoutSeconds: z + .number() + .int() + .min(10, "Response timeout must be between 10 and 600 seconds") + .max(600, "Response timeout must be between 10 and 600 seconds") + .nullable() + .optional(), +}); + +export type AiProviderPatchInput = z.infer; + +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/analytics.ts b/src/lib/validations/analytics.ts new file mode 100644 index 000000000..b714d7bc1 --- /dev/null +++ b/src/lib/validations/analytics.ts @@ -0,0 +1,29 @@ +/** + * Query validation for `GET /api/analytics`. + * + * One parameter, one legal value, and the point of writing it down is that + * the route used to compare the raw string against `"summaries"` and fall + * through to the thick default body on anything else. `?slice=summary` then + * ran the whole 30-query chain and answered as if nothing were wrong, which + * is the most expensive way this surface can absorb a typo. + * + * 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). + */ +import { z } from "zod/v4"; + +/** The slices `GET /api/analytics` knows how to serve. */ +export const ANALYTICS_SLICES = ["summaries"] as const; + +export const analyticsSliceEnum = z.enum(ANALYTICS_SLICES); + +export const analyticsQuerySchema = z.object({ + /** + * `summaries` selects the slim tile-strip body. Omitted selects the thick + * default body. Any other value is refused — falling through to the + * default would hide the typo behind the more expensive read. + */ + slice: analyticsSliceEnum.optional(), +}); + +export type AnalyticsQuery = z.infer; diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 8fcfd7a97..9be51be04 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -264,6 +264,55 @@ const reminderPhaseConfigSchema = z }) .passthrough(); +/** + * One archived schedule era. + * + * `supersededByIndex` is the self-reference, and it is a POSITION in the + * drug's own `scheduleRevisions` array rather than an id: a portable restore + * mints fresh ids, so an id would address nothing. It stays a plain integer + * with no range check here on purpose — the file is the wrong place to learn + * that a position is out of range, because the array it indexes into is the + * array being parsed. The restore does that check, against the rows it + * actually wrote, and reports what it cannot resolve. + * + * `payload` is the snapshot of the superseded schedule rows, carried as + * written. Unknown on purpose rather than typed: it is an era's frozen copy of + * a shape that has changed several times, and a restore must not refuse a file + * because a five-release-old snapshot spells a field the current form does not. + */ +const medicationScheduleRevisionSchema = z + .object({ + id: z.string().min(1).optional(), + validFrom: isoDateTime, + validUntil: isoDateTime, + payload: z.unknown(), + source: z.string().min(1).optional(), + supersededByIndex: z.number().int().nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + +/** + * What a medication was supposed to move. + * + * The lab arm is a biomarker NAME rather than an id, the way a lab result's + * cross-reference is: the id is fresh on a portable restore, and `Biomarker` + * is unique on `(userId, name)`. Both arms are nullable because the live + * schema produces a row with neither — deleting a biomarker sets the column + * NULL and leaves the override behind as an orphan the resolver reads as "no + * override". + */ +const medicationEfficacyTargetSchema = z + .object({ + id: z.string().min(1).optional(), + measurementType: z.enum(MeasurementType).nullable().optional(), + biomarkerName: z.string().min(1).nullable().optional(), + primary: z.boolean().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -307,6 +356,12 @@ const medicationSchema = z inventoryItems: z.array(medicationInventoryItemSchema).default([]), inventoryEvents: z.array(medicationInventoryEventSchema).default([]), phaseConfig: reminderPhaseConfigSchema.nullable().optional(), + // Same default, same reason: an older file parses, and a drug whose + // target the resolver derives rather than the person pinning it writes []. + efficacyTargets: z.array(medicationEfficacyTargetSchema).default([]), + // Same default again. A drug whose plan has never been replaced has no + // archived era, and a file written before the eras travelled has no key. + scheduleRevisions: z.array(medicationScheduleRevisionSchema).default([]), }) .passthrough(); @@ -1514,6 +1569,36 @@ const documentBackupSchema = z }) .passthrough(); +/** + * One ECG strip. + * + * `waveformEncrypted` and `waveform` are the two ends of the same contract, so + * both are optional here and exactly one arrives: a disaster-recovery file + * carries the ciphertext, a portable file carries the micro-volt samples. + * Requiring either would refuse half the valid files. `samplingFrequency` and + * `sampleCount` stay required in both, because without them the samples are a + * list of numbers with no time axis. + */ +const ecgRecordingBackupSchema = z + .object({ + id: z.string().min(1).optional(), + source: z.enum(MeasurementSource), + externalRecordingId: z.string().min(1), + recordedAt: isoDateTime, + waveformEncrypted: base64BytesSchema.optional(), + waveform: z.array(z.number()).optional(), + samplingFrequency: z.number().int(), + sampleCount: z.number().int(), + durationSeconds: z.number().nullable().optional(), + lead: z.string().nullable().optional(), + averageHeartRate: z.number().int().nullable().optional(), + rhythmClassification: z.enum(RhythmClassification).nullable().optional(), + measurementId: z.string().min(1).nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + }) + .passthrough(); + const backupManifestSchema = z .object({ documents: z @@ -1627,6 +1712,10 @@ export const backupPayloadSchema = z environmentTravelLocations: z .array(environmentTravelLocationBackupSchema) .default([]), + // The ECG strips. Defaulted for the same reason as the sections above: a + // file written before the recordings travelled carries no key, and an + // account whose watch has never taken one writes []. + ecgRecordings: z.array(ecgRecordingBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), // v1.37.19 (A6-9) — field paths a PORTABLE export could not decrypt // (fail-soft nulls). Disclosed in the file so a nulled field is @@ -1663,6 +1752,10 @@ export interface BackupSummary { intakeEvents: number; /** Side effects recorded against a drug, across every medication. */ medicationSideEffects: number; + /** Pinned efficacy targets, across every medication. */ + medicationEfficacyTargets: number; + /** Archived schedule eras, across every medication. */ + medicationScheduleRevisions: number; moodEntries: number; /** v1.15.0 — observed cycle spans in the backup. */ cycles: number; @@ -1737,6 +1830,8 @@ export interface BackupSummary { environmentContexts: number; /** Declared stretches spent away from home. */ environmentTravelLocations: number; + /** ECG strips, with their rhythm verdict and their trace. */ + ecgRecordings: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -1751,6 +1846,14 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { (sum, medication) => sum + medication.sideEffects.length, 0, ), + medicationEfficacyTargets: payload.medications.reduce( + (sum, medication) => sum + medication.efficacyTargets.length, + 0, + ), + medicationScheduleRevisions: payload.medications.reduce( + (sum, medication) => sum + medication.scheduleRevisions.length, + 0, + ), moodEntries: payload.moodEntries.length, cycles: payload.cycles.length, cycleDayLogs: payload.cycleDayLogs.length, @@ -1815,6 +1918,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { userAchievements: payload.userAchievements.length, environmentContexts: payload.environmentContexts.length, environmentTravelLocations: payload.environmentTravelLocations.length, + ecgRecordings: payload.ecgRecordings.length, }; } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 3cd8ac1bd..104043716 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -725,6 +725,25 @@ export const updateMeasurementSchema = z.object({ .optional(), }); +/** + * Query params for `GET /api/personal-records`. + * + * Both fields used to be parsed defensively at the route: an unrecognised + * `metricType` was dropped (which WIDENED the read to every metric rather + * than narrowing it, so a typo returned more data than asked for) and a + * garbage `limit` reverted to the default. Neither told the caller anything, + * so a typo was indistinguishable from an omission. Both are refused now. + * + * The 500 ceiling matches every other paginated read on the surface, and it + * refuses rather than clamps for the same reason: silently serving 500 rows + * to a caller that asked for 999 999 is another difference nobody is told + * about. + */ +export const listPersonalRecordsSchema = z.object({ + metricType: measurementTypeEnum.optional(), + limit: z.coerce.number().int().min(1).max(500).optional().default(100), +}); + export const listMeasurementsSchema = z .object({ type: measurementTypeEnum.optional(), 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; diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6964b8c34..a6daaf312 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -58,6 +58,10 @@ import { encryptFactData, encryptFactProvenance, } from "@/lib/documents/store"; +import { + decryptWaveformFromBytes, + encryptWaveformToBytes, +} from "@/lib/withings/ecg-waveform-codec"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; import { POST } from "@/app/api/admin/backups/[id]/restore/route"; @@ -109,6 +113,8 @@ const COACH_ASSISTANT_TURN = const DOSE_CHANGE_NOTE = "titration note, encrypted at rest"; const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)"; const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose"; +/** A short micro-volt strip. Long enough that a defaulted empty array shows. */ +const ECG_SAMPLES = [-12, 4, 118, -33, 7, 2, -5, 61]; beforeEach(async () => { await truncateAllTables(getPrismaClient()); @@ -222,6 +228,12 @@ const COUNT_BACK: Record< p.environmentContext.count({ where: { userId } }), EnvironmentTravelLocation: (p, userId) => p.environmentTravelLocation.count({ where: { userId } }), + EcgRecording: (p, userId) => p.ecgRecording.count({ where: { userId } }), + // No own `userId` column — reached through the drug, like the schedules. + MedicationEfficacyTarget: (p, userId) => + p.medicationEfficacyTarget.count({ where: { medication: { userId } } }), + MedicationScheduleRevision: (p, userId) => + p.medicationScheduleRevision.count({ where: { medication: { userId } } }), }; /** @@ -247,6 +259,37 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { source: "MANUAL", }, }); + // The EVENT row an ECG strip is filed against, and the strip itself. The + // pairing is the point: `EcgRecording.measurementId` is a real foreign key, + // so this is the reference whose remap has to survive the round trip rather + // than fail the transaction. + const rhythmEvent = await prisma.measurement.create({ + data: { + userId: OWNER_ID, + type: "IRREGULAR_RHYTHM_NOTIFICATION", + value: 1, + unit: "event", + measuredAt: AT("2026-06-15T14:22:00.000Z"), + source: "WITHINGS", + rhythmClassification: "IRREGULAR", + }, + }); + await prisma.ecgRecording.create({ + data: { + userId: OWNER_ID, + source: "WITHINGS", + externalRecordingId: "round-trip-signal-1", + recordedAt: AT("2026-06-15T14:22:00.000Z"), + waveformEncrypted: encryptWaveformToBytes(ECG_SAMPLES), + samplingFrequency: 300, + sampleCount: ECG_SAMPLES.length, + durationSeconds: ECG_SAMPLES.length / 300, + lead: "I", + averageHeartRate: 88, + rhythmClassification: "IRREGULAR", + measurementId: rhythmEvent.id, + }, + }); await prisma.intradayCumulativeProfile.create({ data: { userId: OWNER_ID, @@ -426,6 +469,42 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, ], }); + // Three archived eras, and the CHAIN through them is the content. The + // middle one was corrected, so it points at the third; the first and the + // third stand on their own. A restore that returned three rows with every + // link null satisfies any count and has destroyed the only thing the + // pointer says — every era consumer skips a superseded row, so the + // corrected middle era would come back live beside the correction that + // replaced it and the timeline would show the same window twice. + await prisma.medicationScheduleRevision.create({ + data: { + medicationId: medication.id, + validFrom: AT("2026-01-01T00:00:00.000Z"), + validUntil: AT("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }], + source: "ARCHIVED", + }, + }); + const correctionEra = await prisma.medicationScheduleRevision.create({ + data: { + medicationId: medication.id, + validFrom: AT("2026-03-01T00:00:00.000Z"), + validUntil: AT("2026-05-01T00:00:00.000Z"), + payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }], + source: "MANUAL", + }, + }); + await prisma.medicationScheduleRevision.create({ + data: { + medicationId: medication.id, + validFrom: AT("2026-02-01T00:00:00.000Z"), + validUntil: AT("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }], + source: "ARCHIVED", + supersededByRevisionId: correctionEra.id, + }, + }); + await prisma.medicationPauseEra.createMany({ data: [ { @@ -513,6 +592,17 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { takenAt: AT("2026-06-30T09:00:00.000Z"), }, }); + // What the drug above is FOR, pinned to the analyte just created. The two + // rows are seeded at opposite ends of this fixture on purpose: the drug is + // restored hundreds of lines before the analyte, so this pairing is what + // proves the second pass runs late enough to resolve the name. + await prisma.medicationEfficacyTarget.create({ + data: { + medicationId: medication.id, + biomarkerId: biomarker.id, + primary: true, + }, + }); await prisma.allergy.create({ data: { userId: OWNER_ID, substance: "Penicillin" }, }); @@ -1765,6 +1855,155 @@ describe("every model the plan claims two-ended survives a real restore", () => confidence: 0.94, }, }); + // The archived eras, and the CHAIN through them. + // + // Counting says three eras returned, and three eras with every link null + // would satisfy it exactly while having thrown away the only thing the + // pointer states. That is not a cosmetic loss: every era consumer skips a + // superseded row, so a corrected era whose link came back null goes live + // again beside the correction that replaced it, and the account gets two + // overlapping plans for one window with its past compliance minted against + // whichever the query happened to read. + // + // Asserted as the shape of the chain rather than as ids: the ids are fresh + // on a portable restore, and what the two-pass write owes is that era two + // still points at era three and that nothing else points anywhere. + const scheduleEras = await prisma.medicationScheduleRevision.findMany({ + where: { medication: { userId: OWNER_ID } }, + orderBy: [{ validFrom: "asc" }, { createdAt: "asc" }, { id: "asc" }], + }); + const eraPositionById = new Map( + scheduleEras.map((era, index) => [era.id, index]), + ); + expect( + scheduleEras.map((era) => ({ + validFrom: era.validFrom.toISOString(), + validUntil: era.validUntil.toISOString(), + source: era.source, + payload: era.payload, + supersedes: era.supersededByRevisionId + ? (eraPositionById.get(era.supersededByRevisionId) ?? "dangling") + : null, + })), + "the corrected era must come back still pointing at its correction", + ).toEqual([ + { + validFrom: "2026-01-01T00:00:00.000Z", + validUntil: "2026-03-01T00:00:00.000Z", + source: "ARCHIVED", + payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }], + supersedes: null, + }, + { + validFrom: "2026-02-01T00:00:00.000Z", + validUntil: "2026-03-01T00:00:00.000Z", + source: "ARCHIVED", + payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }], + supersedes: 2, + }, + { + validFrom: "2026-03-01T00:00:00.000Z", + validUntil: "2026-05-01T00:00:00.000Z", + source: "MANUAL", + payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }], + supersedes: null, + }, + ]); + // What the pointer actually buys, stated as the thing the app reads: the + // timeline query drops superseded rows, so exactly two eras are live. + expect( + await prisma.medicationScheduleRevision.count({ + where: { + medication: { userId: OWNER_ID }, + supersededByRevisionId: null, + }, + }), + "a lost pointer puts a corrected era back on the timeline", + ).toBe(2); + + // The pinned target, and the analyte it points at. + // + // Counting says one override returned. It cannot say the override still + // MEANS anything: a restore that wrote the row with `biomarkerId` nulled + // returns a drug with no statement of what it was for, which is precisely + // what the register said the alternative cost, and nothing anywhere would + // complain — the resolver silently falls back to deriving a target and the + // page still renders. + // + // Both ends are resolved from the DATABASE rather than remembered from the + // fixture. The property the second pass owes is that the target points at + // the biomarker THIS restore wrote, which an id carried over from the + // seeding would not distinguish from a restore that copied a stale value. + const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + const restoredMedication = await prisma.medication.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + const target = await prisma.medicationEfficacyTarget.findFirstOrThrow({ + where: { medication: { userId: OWNER_ID } }, + include: { biomarker: { select: { name: true, userId: true } } }, + }); + expect({ + medicationId: target.medicationId, + biomarkerId: target.biomarkerId, + biomarkerName: target.biomarker?.name ?? null, + biomarkerOwner: target.biomarker?.userId ?? null, + measurementType: target.measurementType, + primary: target.primary, + }).toEqual({ + medicationId: restoredMedication.id, + biomarkerId: restoredBiomarker.id, + biomarkerName: "Ferritin", + biomarkerOwner: OWNER_ID, + measurementType: null, + primary: true, + }); + + // The strip, read back column by column. + // + // Counting proves a recording returned. It cannot tell that recording from + // one whose trace is an empty array, whose verdict defaulted to NULL and + // whose instant is the restore time — and each of those three is the whole + // value of the row on its own. A strip with no rhythm classification is a + // squiggle nobody has read; a strip with no `recordedAt` belongs to no day + // and cannot be put beside the event it explains; a strip with no samples + // is a claim that an ECG exists which nothing can draw. + // + // `measurementId` is resolved from the DATABASE rather than remembered + // from the fixture, because the property the remap owes is that the strip + // points at the event row THIS restore wrote. + const restoredEvent = await prisma.measurement.findFirstOrThrow({ + where: { userId: OWNER_ID, type: "IRREGULAR_RHYTHM_NOTIFICATION" }, + }); + const strip = await prisma.ecgRecording.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect({ + source: strip.source, + externalRecordingId: strip.externalRecordingId, + recordedAt: strip.recordedAt.toISOString(), + samplingFrequency: strip.samplingFrequency, + sampleCount: strip.sampleCount, + durationSeconds: strip.durationSeconds, + lead: strip.lead, + averageHeartRate: strip.averageHeartRate, + rhythmClassification: strip.rhythmClassification, + measurementId: strip.measurementId, + waveform: decryptWaveformFromBytes(strip.waveformEncrypted), + }).toEqual({ + source: "WITHINGS", + externalRecordingId: "round-trip-signal-1", + recordedAt: "2026-06-15T14:22:00.000Z", + samplingFrequency: 300, + sampleCount: ECG_SAMPLES.length, + durationSeconds: ECG_SAMPLES.length / 300, + lead: "I", + averageHeartRate: 88, + rhythmClassification: "IRREGULAR", + measurementId: restoredEvent.id, + waveform: ECG_SAMPLES, + }); // The appointment's reminder reference survives too — same remap, other // referrer. @@ -1966,6 +2205,121 @@ describe("every model the plan claims two-ended survives a real restore", () => }); }); + /** + * A supersede pointer that names a position the file does not have. + * + * The builder computes the index from the same list it writes, so no file + * this release produces can trip this. A hand-edited or truncated one can, + * and the question it asks has to be answered somewhere: the era it names + * does not exist, the column is a bare id with no constraint to stop a + * wrong value, and inventing a neighbouring era to point at would be worse + * than either dropping or refusing. + * + * The answer is that the ERA restores and the POINTER does not, and the + * position is reported. Refusing the file would cost an operator every era + * of every drug over one bad integer; writing the link would put a + * correction pointer at whatever row happened to be there. + */ + it("keeps an archived era whose supersede pointer names a position out of range, and reports the position", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + + const medication = await prisma.medication.create({ + data: { userId: OWNER_ID, name: "Out-of-range tablet", dose: "5 mg" }, + }); + await prisma.medicationScheduleRevision.createMany({ + data: [ + { + medicationId: medication.id, + validFrom: AT("2026-01-01T00:00:00.000Z"), + validUntil: AT("2026-02-01T00:00:00.000Z"), + payload: [{ windowStart: "07:00", windowEnd: "09:00" }], + source: "ARCHIVED", + }, + { + medicationId: medication.id, + validFrom: AT("2026-02-01T00:00:00.000Z"), + validUntil: AT("2026-03-01T00:00:00.000Z"), + payload: [{ windowStart: "08:00", windowEnd: "10:00" }], + source: "MANUAL", + }, + ], + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + // Hand-edit the file the way a person with a text editor would: point the + // first era at an era that is not there. + const medications = payload.medications as Array<{ + scheduleRevisions: Array<{ supersededByIndex: number | null }>; + }>; + medications[0].scheduleRevisions[0].supersededByIndex = 7; + + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + + const reported = body.data.skipped.catalogueKeys as Array<{ + catalogue: string; + key: string; + links: number; + }>; + expect( + reported.filter((entry) => entry.catalogue === "scheduleRevisionLink"), + "the position the file named is not in the file, so dropping the link " + + "is correct and saying nothing about it is not", + ).toEqual([ + { + catalogue: "scheduleRevisionLink", + key: "medications[0].scheduleRevisions[0].supersededByIndex=7", + links: 1, + }, + ]); + + const restoredEras = await prisma.medicationScheduleRevision.findMany({ + where: { medication: { userId: OWNER_ID } }, + orderBy: { validFrom: "asc" }, + }); + expect( + restoredEras.map((era) => ({ + validFrom: era.validFrom.toISOString(), + source: era.source, + supersededByRevisionId: era.supersededByRevisionId, + })), + "both eras come back; only the pointer is lost", + ).toEqual([ + { + validFrom: "2026-01-01T00:00:00.000Z", + source: "ARCHIVED", + supersededByRevisionId: null, + }, + { + validFrom: "2026-02-01T00:00:00.000Z", + source: "MANUAL", + supersededByRevisionId: null, + }, + ]); + }); + /** * The reference a portable file genuinely lacks, and the slug the restore * must not judge.