diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7c102ba6d..614e36ca7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1436,6 +1436,57 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/measurements/series: + get: + tags: + - Measurements + summary: Per-kind time series (iOS chart source) + description: Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an + explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`) + within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night + point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to + 365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Auth via cookie or Bearer. + parameters: + - in: query + name: kind + schema: + type: string + enum: + - weight + - bloodPressure + - pulse + - bodyFat + - glucose + - sleep + - steps + - totalBodyWater + - boneMass + - oxygenSaturation + - restingHeartRate + - heartRateVariability + - vo2Max + required: true + - in: query + name: days + schema: + default: 30 + description: Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the + requested range. + type: integer + minimum: 1 + maximum: 3650 + description: Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the + requested range. + responses: + "200": + description: Resolved series. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMeasurementsSeriesResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/measurements: get: tags: @@ -2832,6 +2883,25 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/user/ai-provider: + get: + tags: + - Auth + summary: Read the calling user's AI-provider configuration + description: "Returns the user's selected provider/model/baseUrl plus the effective availability: `aiAvailable` is true + when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy` + reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char + masked preview only." + responses: + "200": + description: Resolved AI-provider configuration. + content: + application/json: + schema: + $ref: "#/components/schemas/GetAiProviderResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/me/coach-prefs: get: tags: @@ -8286,6 +8356,113 @@ components: - updatedAt additionalProperties: false description: A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page. + GetMeasurementsSeriesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementsSeriesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementsSeriesResponse: + type: object + properties: + kind: + type: string + enum: + - weight + - bloodPressure + - pulse + - bodyFat + - glucose + - sleep + - steps + - totalBodyWater + - boneMass + - oxygenSaturation + - restingHeartRate + - heartRateVariability + - vo2Max + unit: + type: string + description: Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` + (per-night time-asleep in hours). + points: + type: array + items: + type: object + properties: + id: + type: string + description: Measurement row id, or `sleep:` for a sleep night. + at: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Point timestamp (ISO-8601). + value: + type: number + description: Primary value in the top-level `unit`. + secondary: + anyOf: + - type: number + - type: "null" + description: Diastolic value for `kind=bloodPressure` (paired within ±5 min); null for every other kind. + sleepStages: + description: Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds. + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: number + - type: "null" + required: + - id + - at + - value + - secondary + additionalProperties: false + stats: + type: object + properties: + mean: + type: number + min: + type: number + max: + type: number + stdDev: + type: number + count: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - mean + - min + - max + - stdDev + - count + additionalProperties: false + description: Summary over the returned points; all-zero when empty. + required: + - kind + - unit + - points + - stats + additionalProperties: false + description: "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an + explicit `unit` token, and a summary `stats` block." ListMeasurementsResponse: type: object properties: @@ -10695,6 +10872,89 @@ components: - data - error additionalProperties: false + GetAiProviderResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AiProviderResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AiProviderResponse: + type: object + properties: + provider: + anyOf: + - type: string + enum: + - OPENAI + - ANTHROPIC + - LOCAL + - CHATGPT_OAUTH + - type: "null" + description: The user's selected provider, or null when none is set. + model: + anyOf: + - type: string + - type: "null" + baseUrl: + anyOf: + - type: string + - type: "null" + description: Custom base URL (LOCAL provider only); null otherwise. + aiAvailable: + type: boolean + description: True when ANY provider can serve this user — including the operator's server-managed key when the user set + none. iOS keys Coach visibility off this. + managedBy: + anyOf: + - type: string + enum: + - user + - local + - server + - type: "null" + description: "Resolved provider origin: `user` (BYOK), `local` (self-hosted), `server` (operator key), or null when no + provider is available." + hasAnthropicKey: + type: boolean + anthropicKeyPreview: + anyOf: + - type: string + - type: "null" + description: "`...` + last 4 chars of the stored Anthropic key, or null." + hasLocalKey: + type: boolean + hasOpenaiKey: + type: boolean + openaiKeyPreview: + anyOf: + - type: string + - type: "null" + description: "`...` + last 4 chars of the stored OpenAI key, or null." + required: + - provider + - model + - baseUrl + - aiAvailable + - managedBy + - hasAnthropicKey + - anthropicKeyPreview + - hasLocalKey + - hasOpenaiKey + - openaiKeyPreview + additionalProperties: false + description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). + Reports key presence + a masked preview only — never a plaintext key. GetCoachPrefsResponse: type: object properties: diff --git a/prisma/migrations/0159_v11616_consent_active_unique/migration.sql b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql index dae858430..5f571da13 100644 --- a/prisma/migrations/0159_v11616_consent_active_unique/migration.sql +++ b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql @@ -20,6 +20,14 @@ -- (latest `created_at`) is the one `latestActiveReceipt` already returns, so -- the surviving active grant matches current read behaviour; the superseded -- duplicates get a `revoked_at` marker and stay in the audit history. +-- +-- The sibling predicate breaks on `(created_at, id)`, not `created_at` alone: +-- two active rows with an EXACT `created_at` tie would otherwise leave each +-- without a strictly-greater sibling, so neither is revoked and the +-- `CREATE UNIQUE INDEX` below aborts (23505), failing the deploy on exactly +-- the tenants this migration heals. The `id` tie-break is total (PK), so each +-- duplicate set keeps precisely one survivor — the row with the greatest +-- `(created_at, id)`, preserving the "newest survives" intent. UPDATE "consent_receipts" c SET "revoked_at" = now() WHERE "revoked_at" IS NULL @@ -28,7 +36,10 @@ UPDATE "consent_receipts" c WHERE n."user_id" = c."user_id" AND n."kind" = c."kind" AND n."revoked_at" IS NULL - AND n."created_at" > c."created_at" + AND ( + n."created_at" > c."created_at" + OR (n."created_at" = c."created_at" AND n."id" > c."id") + ) ); CREATE UNIQUE INDEX IF NOT EXISTS "consent_receipts_user_id_kind_active_key" diff --git a/src/lib/consent/__tests__/receipts.test.ts b/src/lib/consent/__tests__/receipts.test.ts index d26fa43df..886d72369 100644 --- a/src/lib/consent/__tests__/receipts.test.ts +++ b/src/lib/consent/__tests__/receipts.test.ts @@ -84,6 +84,50 @@ describe("createReceipt", () => { expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); + it("resolves a concurrent first-grant P2002 to the winning active row (no 500)", async () => { + // Two concurrent FIRST grants: both `updateMany` calls match 0 rows (no + // prior active receipt, no lock under Read Committed), both reach + // `create`, and the second trips the partial unique index with P2002. + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + name: "PrismaClientKnownRequestError", + clientVersion: "x", + }); + vi.mocked(prisma.consentReceipt.create).mockRejectedValue(p2002 as never); + // The concurrent grant that won is now the single active receipt. + const winner = row({ id: "winner-row" }); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue( + winner as never, + ); + + const signedAt = new Date("2026-05-18T10:00:00.000Z"); + const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt); + + // Success-shaped: returns the row the concurrent grant produced, not a + // propagated P2002 (which the route would surface as a generic 500). + expect(result.id).toBe("winner-row"); + expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("re-throws a non-P2002 create failure", async () => { + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.consentReceipt.create).mockRejectedValue( + new Error("connection reset") as never, + ); + + await expect( + createReceipt("user-1", "ai_full", "PDF…", new Date()), + ).rejects.toThrow("connection reset"); + }); + it("uses a server clock (not a backdated client signedAt) for the supersede marker", async () => { vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ count: 1, diff --git a/src/lib/consent/receipts.ts b/src/lib/consent/receipts.ts index 38e1487fd..95c93abde 100644 --- a/src/lib/consent/receipts.ts +++ b/src/lib/consent/receipts.ts @@ -15,6 +15,7 @@ import { prisma } from "@/lib/db"; import type { ConsentReceiptModel } from "@/generated/prisma/models/ConsentReceipt"; import type { ConsentKind } from "@/lib/validations/consent"; +import { isP2002 } from "@/lib/prisma-errors"; export type ConsentReceipt = ConsentReceiptModel; @@ -44,23 +45,38 @@ export async function createReceipt( // old row's `revoked_at` earlier than its own `signed_at`, inverting the // audit chain. Use a server clock for the revocation instead. const supersededAt = new Date(); - return prisma.$transaction(async (tx) => { - // Supersede any currently-active receipt of this kind so the fresh - // grant doesn't collide with the partial unique index. `updateMany` - // over the active predicate is a no-op when none is active. - await tx.consentReceipt.updateMany({ - where: { userId, kind, revokedAt: null }, - data: { revokedAt: supersededAt }, + try { + return await prisma.$transaction(async (tx) => { + // Supersede any currently-active receipt of this kind so the fresh + // grant doesn't collide with the partial unique index. `updateMany` + // over the active predicate is a no-op when none is active. + await tx.consentReceipt.updateMany({ + where: { userId, kind, revokedAt: null }, + data: { revokedAt: supersededAt }, + }); + return tx.consentReceipt.create({ + data: { + userId, + kind, + artefact, + signedAt, + }, + }); }); - return tx.consentReceipt.create({ - data: { - userId, - kind, - artefact, - signedAt, - }, - }); - }); + } catch (err) { + // Two concurrent FIRST grants (no prior active row) both see + // `updateMany` match zero rows under Read Committed — neither takes a + // lock — so both reach `create` and the second trips the partial unique + // index (migration 0159, `WHERE revoked_at IS NULL`) with P2002. That is + // success-shaped: the concurrent grant already produced the single active + // receipt the caller wants. Re-read it and return it rather than letting + // the violation surface as a generic 500. + if (isP2002(err)) { + const winner = await latestActiveReceipt(userId, kind); + if (winner) return winner; + } + throw err; + } } /** diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index d89852a44..8316a6c25 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -216,7 +216,112 @@ const sleepNightResponse = z "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.", }); +// ── Time-series adapter (iOS chart source) ─────────────────────────── +// +// v1.16.16 — documents the long-shipped `GET /api/measurements/series` +// the iOS charts read off. The response is described AS-IS: `points` +// carry `id`/`at`/`value`/`secondary` (the route's wire field names), +// not the canonical `measuredAt`. `secondary` pairs the diastolic value +// for `bloodPressure`; `sleepStages` is per-night stage hours for `sleep` +// and null otherwise. `unit` is the resolved per-kind token (glucose + +// sleep resolve at request time to the user's display unit / hours). +const seriesKindEnum = z.enum([ + "weight", + "bloodPressure", + "pulse", + "bodyFat", + "glucose", + "sleep", + "steps", + "totalBodyWater", + "boneMass", + "oxygenSaturation", + "restingHeartRate", + "heartRateVariability", + "vo2Max", +]); + +const seriesQuerySchema = z.object({ + kind: seriesKindEnum, + days: z.coerce + .number() + .int() + .min(1) + .max(3650) + .optional() + .default(30) + .describe( + "Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the requested range.", + ), +}); + +const seriesPointSchema = z.object({ + id: z.string().describe("Measurement row id, or `sleep:` for a sleep night."), + at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."), + value: z.number().describe("Primary value in the top-level `unit`."), + secondary: z + .number() + .nullable() + .describe( + "Diastolic value for `kind=bloodPressure` (paired within ±5 min); null for every other kind.", + ), + sleepStages: z + .record(z.string(), z.number()) + .nullable() + .optional() + .describe( + "Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds.", + ), +}); + +const seriesResponse = z + .object({ + kind: seriesKindEnum, + unit: z + .string() + .describe( + "Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours).", + ), + points: z.array(seriesPointSchema), + stats: z + .object({ + mean: z.number(), + min: z.number(), + max: z.number(), + stdDev: z.number(), + count: z.number().int().nonnegative(), + }) + .describe("Summary over the returned points; all-zero when empty."), + }) + .meta({ + id: "MeasurementsSeriesResponse", + description: + "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block.", + }); + export const measurementPaths: NonNullable = { + "/api/measurements/series": { + get: { + tags: ["Measurements"], + summary: "Per-kind time series (iOS chart source)", + description: + "Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`) within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to 365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Auth via cookie or Bearer.", + requestParams: { + query: seriesQuerySchema, + }, + responses: { + "200": { + description: "Resolved series.", + content: { + "application/json": { + schema: dataEnvelope(seriesResponse, "GetMeasurementsSeriesResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/measurements": { get: { tags: ["Measurements"], diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index ac7bbe491..ea303ff85 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -215,7 +215,73 @@ const profileUpdateResponse = z "Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value.", }); +// v1.16.16 — AI-provider read surface. iOS gates Coach visibility off +// `aiAvailable` + `managedBy` so a server-managed (operator-key) provider +// is no longer invisible to the client. The response reports key PRESENCE +// + a 4-char masked preview only; no plaintext key or admin endpoint is +// ever surfaced. `managedBy` reports the resolved provider origin: `user` +// (BYOK), `local` (self-hosted Ollama/LM Studio), `server` (operator's +// shared key), or null when no provider can serve the caller. +const aiProviderResponse = z + .object({ + provider: z + .enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]) + .nullable() + .describe("The user's selected provider, or null when none is set."), + model: z.string().nullable(), + baseUrl: z + .string() + .nullable() + .describe("Custom base URL (LOCAL provider only); null otherwise."), + aiAvailable: z + .boolean() + .describe( + "True when ANY provider can serve this user — including the operator's server-managed key when the user set none. iOS keys Coach visibility off this.", + ), + managedBy: z + .enum(["user", "local", "server"]) + .nullable() + .describe( + "Resolved provider origin: `user` (BYOK), `local` (self-hosted), `server` (operator key), or null when no provider is available.", + ), + hasAnthropicKey: z.boolean(), + anthropicKeyPreview: z + .string() + .nullable() + .describe("`...` + last 4 chars of the stored Anthropic key, or null."), + hasLocalKey: z.boolean(), + hasOpenaiKey: z.boolean(), + openaiKeyPreview: z + .string() + .nullable() + .describe("`...` + last 4 chars of the stored OpenAI key, or null."), + }) + .meta({ + id: "AiProviderResponse", + description: + "The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). Reports key presence + a masked preview only — never a plaintext key.", + }); + export const profilePaths: NonNullable = { + "/api/user/ai-provider": { + get: { + tags: ["Auth"], + summary: "Read the calling user's AI-provider configuration", + description: + "Returns the user's selected provider/model/baseUrl plus the effective availability: `aiAvailable` is true when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy` reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char masked preview only.", + responses: { + "200": { + description: "Resolved AI-provider configuration.", + content: { + "application/json": { + schema: dataEnvelope(aiProviderResponse, "GetAiProviderResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/coach-prefs": { get: { tags: ["Auth"],