Harden consent receipts and document iOS series + ai-provider contract

Document GET /api/measurements/series and GET /api/user/ai-provider in the
OpenAPI registry — both are consumed by the iOS client (charts and Coach
visibility gating) but were absent from the spec.

Break migration 0159's active-receipt dedup on (created_at, id) so an exact
created_at tie still leaves one survivor, keeping the unique index build from
aborting 23505 on affected tenants.

Catch P2002 in createReceipt: two concurrent first grants both reach create,
the loser re-reads the winning active receipt and returns it instead of a 500.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:42:00 +02:00
1 parent 555240cd28
commit 32d275b4aa
6 files changed
+519 -17

No files matched your search

+260
View File
@@ -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:<wake-day>` 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: