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

@@ -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,
+32 -16
View File
@@ -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;
}
}
/**
+105
View File
@@ -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:<wake-day>` 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<ZodOpenApiObject["paths"]> = {
"/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"],
+66
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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"],