From 27b548ab13e416faa5e702401c01b2533b3036e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 23 Jul 2026 21:15:17 +0200 Subject: [PATCH] feat(medications,measurements): derive write provenance from transport and surface it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause the intake source attribution and give the HealthKit batch a diagnostic sync-trigger, one attribution surface for both. AuthContext now carries a read-only authMethod ("cookie" | "bearer"), derived from the same branch that sets the wide event's auth_method. The medication-intake route maps it to the stored IntakeSource: a Bearer (native) call records API, a cookie (browser) call records WEB. The other producer values are untouched — a converge onto a pending reminder row updates through applyCanonicalSlotWrite, which never rewrites source, so REMINDER / IMPORT / APPLE_HEALTH provenance is preserved. The value is never client-asserted, the same posture as userId being narrowed from auth. The dose-history ledger now selects and returns source per intake row so a client can label how each dose was recorded. The endpoint gains its first OpenAPI entry (path plus response schema) so the field lands in the iOS-consumed contract. The measurements batch accepts an optional per-request syncTrigger (foreground | background | push). It is diagnostic-only: recorded on the measurement.batch.ingest wide event and nothing else — never persisted, never part of the dedup key, never an input to attribution or how a sample is stored. Sending it or omitting it produces byte-identical row outcomes. Regenerated docs/api/openapi.yaml. --- docs/api/openapi.yaml | 220 ++++++++++++++++++ .../admin/ai-settings/__tests__/route.test.ts | 1 + .../admin/app-logs/__tests__/route.test.ts | 1 + .../admin/audit-log/__tests__/route.test.ts | 1 + .../audit-log/actions/__tests__/route.test.ts | 1 + .../admin/settings/__tests__/route.test.ts | 1 + .../generate/__tests__/route.test.ts | 1 + .../api/admin/status/__tests__/route.test.ts | 1 + .../admin/users/[id]/__tests__/route.test.ts | 1 + .../api/admin/users/__tests__/route.test.ts | 1 + src/app/api/measurements/batch/route.ts | 16 +- .../medications/[id]/dose-history/route.ts | 8 + src/app/api/medications/[id]/intake/route.ts | 17 +- src/lib/api-handler.ts | 18 +- .../medications/scheduling/dose-history.ts | 9 + src/lib/openapi/routes/measurements.ts | 11 + src/lib/openapi/routes/medications/paths.ts | 32 +++ src/lib/openapi/routes/medications/schemas.ts | 95 ++++++++ 18 files changed, 426 insertions(+), 9 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 12a39b1c2..99bd2c302 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4090,6 +4090,56 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/medications/{id}/dose-history: + get: + tags: + - Medications + summary: Per-slot dose-history ledger (Verlauf tab) + description: "Returns the full per-slot ledger for the medication over [from, to]: every expected slot with a status + (taken on-time / taken late / skipped / missed / upcoming) plus every off-schedule intake tagged ad-hoc. Built + from the SAME band minter + `reconstructDoseHistory` the compliance % consumes, so the history view and the rate + can never disagree. v1.32.8 (iOS #64) surfaces `intake.source` so a client can label how each dose was recorded. + Ownership-scoped; rate-limited 60/min/user; reads only non-deleted rows. Auth via cookie or Bearer." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: from + schema: + description: Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a + 366-day span floor. + 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: Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a + 366-day span floor. + - in: query + name: to + schema: + description: Window end (inclusive). Defaults to now. Must be ≥ `from`. + 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: Window end (inclusive). Defaults to now. Must be ≥ `from`. + responses: + "200": + description: Dose-history ledger. + content: + application/json: + schema: + $ref: "#/components/schemas/GetDoseHistoryResponse" + "401": *a1 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/medications/{id}/schedule-revisions: get: tags: @@ -11828,6 +11878,16 @@ components: type: array items: $ref: "#/components/schemas/AppleHealthBatchEntry" + syncTrigger: + description: "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake) + so an operator can see, per batch, which trigger produced it. Recorded on the ingest wide event and nowhere + else — it does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: + pre-#66 clients omit it." + type: string + enum: + - foreground + - background + - push required: - entries description: Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`. @@ -20859,6 +20919,166 @@ components: - data - error additionalProperties: false + GetDoseHistoryResponse: + type: object + properties: + data: + $ref: "#/components/schemas/DoseHistoryResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DoseHistoryResponse: + type: object + properties: + from: + 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)))$ + to: + 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)))$ + family: + type: string + enum: + - daily + - weekly + - one_shot + - none + description: Cadence family the window's slots were minted under. + hasExpectedSlots: + type: boolean + rows: + type: array + items: + $ref: "#/components/schemas/DoseHistoryRow" + required: + - from + - to + - family + - hasExpectedSlots + - rows + additionalProperties: false + description: "Per-slot dose ledger over [from, to]: every expected slot with a status plus every off-schedule intake + tagged ad-hoc. Built from the same band minter + `reconstructDoseHistory` the compliance % consumes, so the + history view and the rate never contradict each other." + DoseHistoryRow: + type: object + properties: + kind: + type: string + enum: + - slot + - ad_hoc + description: "`slot` — a scheduled dose window; `ad_hoc` — a standalone off-schedule intake." + 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: The slot anchor instant, or the ad-hoc take's own time. + timeOfDay: + anyOf: + - type: string + - type: "null" + description: The slot's `HH:mm` label, or null for an ad-hoc row. + status: + type: string + enum: + - taken_on_time + - taken_late + - skipped + - missed + - upcoming + - ad_hoc + pinned: + description: Present and true when the row is served by a deliberate user pin ('zugeordnet'). + type: boolean + nearestSlot: + description: "Due-context for an ad-hoc take: the nearest slot it could belong to (preferring an unserved one). `filled` + false means the slot can still be offered for pinning." + type: object + properties: + 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)))$ + timeOfDay: + type: string + filled: + type: boolean + required: + - at + - timeOfDay + - filled + additionalProperties: false + intake: + anyOf: + - type: object + properties: + id: + anyOf: + - type: string + - type: "null" + scheduledFor: + 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)))$ + takenAt: + anyOf: + - 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)))$ + - type: "null" + skipped: + type: boolean + autoMissed: + type: boolean + doseTaken: + anyOf: + - type: string + - type: "null" + description: Per-intake dose override; null = configured dose. + source: + anyOf: + - type: string + enum: + - WEB + - API + - REMINDER + - IMPORT + - APPLE_HEALTH + - type: "null" + description: "v1.32.8 (iOS #64) — how the dose was recorded: `WEB` (browser), `API` (Bearer / native app), `REMINDER` + (the medication reminder worker), `IMPORT` (CSV importer), `APPLE_HEALTH` (the HealthKit dose-event + mirror). Null on legacy rows written before the column carried a value. Derived server-side from the + write transport, never client-asserted." + required: + - id + - scheduledFor + - takenAt + - skipped + - autoMissed + - doseTaken + - source + additionalProperties: false + - type: "null" + description: The intake attributed to this row, if any. + required: + - kind + - at + - timeOfDay + - status + - intake + additionalProperties: false ListScheduleRevisionsResponse: type: object properties: diff --git a/src/app/api/admin/ai-settings/__tests__/route.test.ts b/src/app/api/admin/ai-settings/__tests__/route.test.ts index 6973b278c..7f73335bf 100644 --- a/src/app/api/admin/ai-settings/__tests__/route.test.ts +++ b/src/app/api/admin/ai-settings/__tests__/route.test.ts @@ -58,6 +58,7 @@ import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; const ADMIN_CTX = { + authMethod: "cookie" as const, session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "admin-1", diff --git a/src/app/api/admin/app-logs/__tests__/route.test.ts b/src/app/api/admin/app-logs/__tests__/route.test.ts index d12a12c5d..adfff8b0f 100644 --- a/src/app/api/admin/app-logs/__tests__/route.test.ts +++ b/src/app/api/admin/app-logs/__tests__/route.test.ts @@ -26,6 +26,7 @@ import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer"; import type { WideEvent } from "@/lib/logging/types"; const ADMIN_CTX = { + authMethod: "cookie" as const, session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "admin-1", username: "admin", role: "ADMIN" } as never, }; diff --git a/src/app/api/admin/audit-log/__tests__/route.test.ts b/src/app/api/admin/audit-log/__tests__/route.test.ts index 600ee91ff..ce8a51090 100644 --- a/src/app/api/admin/audit-log/__tests__/route.test.ts +++ b/src/app/api/admin/audit-log/__tests__/route.test.ts @@ -35,6 +35,7 @@ import { prisma } from "@/lib/db"; import { requireAdmin } from "@/lib/api-handler"; const ADMIN_CTX = { + authMethod: "cookie" as const, session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "admin-1", username: "admin", role: "ADMIN" } as never, }; diff --git a/src/app/api/admin/audit-log/actions/__tests__/route.test.ts b/src/app/api/admin/audit-log/actions/__tests__/route.test.ts index 9e0ed356e..b74307f96 100644 --- a/src/app/api/admin/audit-log/actions/__tests__/route.test.ts +++ b/src/app/api/admin/audit-log/actions/__tests__/route.test.ts @@ -32,6 +32,7 @@ import { prisma } from "@/lib/db"; import { requireAdmin } from "@/lib/api-handler"; const ADMIN_CTX = { + authMethod: "cookie" as const, session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "admin-1", username: "admin", role: "ADMIN" } as never, }; diff --git a/src/app/api/admin/settings/__tests__/route.test.ts b/src/app/api/admin/settings/__tests__/route.test.ts index 599f02ab4..60a550032 100644 --- a/src/app/api/admin/settings/__tests__/route.test.ts +++ b/src/app/api/admin/settings/__tests__/route.test.ts @@ -45,6 +45,7 @@ import { encrypt } from "@/lib/crypto"; import { auditLog } from "@/lib/auth/audit"; const ADMIN_CTX = { + authMethod: "cookie" as const, session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "admin-1", diff --git a/src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts b/src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts index 105287ede..8ae9bbbc6 100644 --- a/src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts +++ b/src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts @@ -55,6 +55,7 @@ import { encrypt } from "@/lib/crypto"; import { auditLog } from "@/lib/auth/audit"; const ADMIN_CTX = { + authMethod: "cookie" as const, session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "admin-1", username: "admin", role: "ADMIN" } as never, }; diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index 3406c07a1..6f9afc609 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -50,6 +50,7 @@ import { prisma } from "@/lib/db"; import { requireAdmin, HttpError } from "@/lib/api-handler"; const ADMIN_CTX = { + authMethod: "cookie" as const, session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "admin-1", diff --git a/src/app/api/admin/users/[id]/__tests__/route.test.ts b/src/app/api/admin/users/[id]/__tests__/route.test.ts index eaaf4592e..7ef8e1828 100644 --- a/src/app/api/admin/users/[id]/__tests__/route.test.ts +++ b/src/app/api/admin/users/[id]/__tests__/route.test.ts @@ -40,6 +40,7 @@ import { requireAdmin, HttpError } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; const ADMIN_CTX = { + authMethod: "cookie" as const, session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "admin-1", diff --git a/src/app/api/admin/users/__tests__/route.test.ts b/src/app/api/admin/users/__tests__/route.test.ts index 6ed74c4fc..4b1038de2 100644 --- a/src/app/api/admin/users/__tests__/route.test.ts +++ b/src/app/api/admin/users/__tests__/route.test.ts @@ -36,6 +36,7 @@ describe("GET /api/admin/users", () => { beforeEach(() => { vi.clearAllMocks(); mockRequireAdmin.mockResolvedValue({ + authMethod: "cookie" as const, session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "admin-1", diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 2e65a693b..1fe4aee66 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -159,8 +159,18 @@ const batchEntrySchema = z.object({ deviceType: deviceTypeEnum.nullable().optional(), }); +// v1.32.8 (iOS #66) — optional per-REQUEST diagnostic tag naming what woke the +// client for this sync. Purely observational: it flows into the +// `measurement.batch.ingest` wide event and NOTHING else — never persisted, +// never part of the dedup key, never an input to attribution or how a sample is +// stored. Only the client knows its own wake reason, so it is client-asserted +// by necessity; being diagnostic-only, that carries no trust surface. Optional +// so every pre-#66 caller stays byte-for-byte unchanged. +const syncTriggerEnum = z.enum(["foreground", "background", "push"]); + const batchPayloadSchema = z.object({ entries: z.array(batchEntrySchema).min(1), + syncTrigger: syncTriggerEnum.optional(), }); type BatchEntry = z.infer; @@ -248,7 +258,7 @@ async function postBatch(request: NextRequest): Promise { return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422); } - const { entries } = parsed.data; + const { entries, syncTrigger } = parsed.data; // First pass — map each inbound entry; record skipped entries. type Prepared = { @@ -784,6 +794,10 @@ async function postBatch(request: NextRequest): Promise { // `skippedByReason` build above. skipped_by_reason: skippedByReason, failed: failedCount, + // v1.32.8 (iOS #66) — diagnostic sync-trigger tag (foreground / + // background / push), or null when the client did not send one. + // Observability only; it never influenced any count above. + syncTrigger: syncTrigger ?? null, }, }); diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 1f01100bb..11c60c9a0 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -87,6 +87,8 @@ interface SerializedDoseHistoryRow { autoMissed: boolean; /** v1.16.4 — per-intake dose override; null = configured dose. */ doseTaken: string | null; + /** v1.32.8 (iOS #64) — how the dose was recorded; null on legacy rows. */ + source: "WEB" | "API" | "REMINDER" | "IMPORT" | "APPLE_HEALTH" | null; } | null; } @@ -161,6 +163,9 @@ export const GET = apiHandler( attributionSource: true, // v1.16.4 — per-intake dose override for the ledger's deviation hint. doseTaken: true, + // v1.32.8 (iOS #64) — write provenance so the client can label how + // each dose was recorded (Web / API / Reminder / Import / AppleHealth). + source: true, }, }); @@ -172,6 +177,7 @@ export const GET = apiHandler( autoMissed: e.autoMissed, pinned: e.attributionSource === "USER_PIN", doseTaken: e.doseTaken, + source: e.source, })); const lastIntakeAt = lastNonSkippedTakenAt(mapped); @@ -244,6 +250,7 @@ export const GET = apiHandler( autoMissed: e.autoMissed, pinned: e.pinned, doseTaken: e.doseTaken, + source: e.source, })); const rows = reconstructDoseHistory(bands, historyIntakes, now); @@ -269,6 +276,7 @@ export const GET = apiHandler( skipped: row.intake.skipped, autoMissed: row.intake.autoMissed ?? false, doseTaken: row.intake.doseTaken ?? null, + source: row.intake.source ?? null, } : null, })); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 042d4e587..5fe7aed25 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -43,7 +43,16 @@ export const POST = apiHandler( ); async function postIntake(request: NextRequest, { params }: RouteParams) { - const { user } = await requireAuth(); + const { user, authMethod } = await requireAuth(); + // v1.32.8 (iOS #64) — write provenance derived from the transport, never + // client-asserted: a Bearer/native call stores `API`, a cookie/browser call + // stores `WEB`. The other `IntakeSource` values are producer-owned by OTHER + // routes (the Telegram worker mints `REMINDER`, the bulk/import paths mint + // `IMPORT` / `APPLE_HEALTH`), and this route only ever CREATES its own rows — + // a converge onto a pending `REMINDER` row updates through + // `applyCanonicalSlotWrite`, which never rewrites `source`, so that + // provenance is preserved. + const intakeSource = authMethod === "bearer" ? "API" : "WEB"; const { id } = await params; // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. @@ -316,7 +325,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { isExplicitTaken, isExplicitSkip, idempotencyKey: idempotencyKey ?? null, - createSource: "WEB", + createSource: intakeSource, // v1.8.5 — resolved + validated site (null unless a tracking-on // injection taken write supplied an allowed site). injectionSite: resolvedInjectionSite, @@ -382,7 +391,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { isExplicitTaken, isExplicitSkip, idempotencyKey: idempotencyKey ?? null, - createSource: "WEB", + createSource: intakeSource, injectionSite: resolvedInjectionSite, attributionSource, doseTaken: resolvedDoseTaken, @@ -409,7 +418,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { scheduledFor: resolvedTakenAt ?? incomingScheduledFor, takenAt: resolvedTakenAt, skipped, - source: "WEB", + source: intakeSource, idempotencyKey: idempotencyKey ?? null, // v1.8.5 — site only on a resolved taken-injection write. ...(resolvedInjectionSite !== null && { diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 96b32e4a9..48ac102a3 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -294,6 +294,15 @@ export function apiHandler Promise>( export type AuthContext = { session: { id: string; expiresAt: Date }; user: User; + /** + * Transport the caller authenticated with, derived from the SAME branch that + * sets the wide event's `auth_method`: the cookie-session path resolves + * `"cookie"`, the Bearer-token path resolves `"bearer"`. Read-only and never + * client-asserted — it is the transport fact, not a request field. A route + * that must record write provenance (e.g. the medication-intake `source`) + * maps it server-side, the same posture as `userId` being narrowed from auth. + */ + readonly authMethod: "cookie" | "bearer"; }; /** @@ -326,7 +335,7 @@ export async function requireAuth( auth_method: "session", }); } - return sessionData; + return { ...sessionData, authMethod: "cookie" }; } // 2. Bearer-token path. @@ -453,6 +462,7 @@ async function authenticateBearer( return { session: { id: tokenId, expiresAt }, user, + authMethod: "bearer", }; } @@ -477,7 +487,7 @@ export async function requireAdmin(): Promise { if (sessionData.user.role !== "ADMIN") { throw new HttpError(403, "Admin access required"); } - return sessionData; + return { ...sessionData, authMethod: "cookie" }; } /** @@ -502,7 +512,7 @@ export async function requireCookieAuth(): Promise { auth_method: "session", }); } - return sessionData; + return { ...sessionData, authMethod: "cookie" }; } /** @@ -585,7 +595,7 @@ export async function requireFreshMfa( throw new StepUpRequiredError(); } - return { ...sessionData, mfaVerifiedAt: verifiedAt }; + return { ...sessionData, authMethod: "cookie", mfaVerifiedAt: verifiedAt }; } /** diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index 0acfcbec8..266cbd03d 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -64,6 +64,15 @@ export interface HistoryIntake { * applies. Pure pass-through for the read ledger (no status impact). */ doseTaken?: string | null; + /** + * v1.32.8 (iOS #64) — write provenance of the stored intake row: `WEB` + * (browser cookie), `API` (Bearer / native app), `REMINDER` (the Telegram + * worker), `IMPORT` (CSV importer), `APPLE_HEALTH` (the HealthKit dose-event + * mirror). Pure pass-through for the read ledger (no status impact); lets a + * client label how each dose was recorded. Optional so legacy callers / + * fixtures omit it. + */ + source?: "WEB" | "API" | "REMINDER" | "IMPORT" | "APPLE_HEALTH"; } export type DoseHistoryStatus = diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index b02c6c217..8a810c4c3 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -110,6 +110,17 @@ const batchEntrySchema = z const batchPayloadSchema = z .object({ entries: z.array(batchEntrySchema).min(1).max(500), + // v1.32.8 (iOS #66) — optional per-request diagnostic tag naming what woke + // the client for this sync. Observability only: it is recorded on the + // ingest wide event and NEVER persisted, deduped on, or used in + // attribution — sending it or omitting it produces byte-identical row + // outcomes. Omit on any client that does not track its wake reason. + syncTrigger: z + .enum(["foreground", "background", "push"]) + .optional() + .describe( + "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake) so an operator can see, per batch, which trigger produced it. Recorded on the ingest wide event and nowhere else — it does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.", + ), }) .meta({ id: "AppleHealthBatchRequest", diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index ea92a0eed..de0c5dc41 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -48,6 +48,8 @@ import { scheduleRevisionListResponse, medicationExtractRequest, medicationListLayoutSchema, + doseHistoryQuery, + doseHistoryResponse, } from "./schemas"; // ── Bulk intake backfill (iOS SyncMode) — mirrors the route's @@ -743,6 +745,36 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/{id}/dose-history": { + get: { + tags: ["Medications"], + summary: "Per-slot dose-history ledger (Verlauf tab)", + description: + "Returns the full per-slot ledger for the medication over [from, to]: every expected slot with a status (taken on-time / taken late / skipped / missed / upcoming) plus every off-schedule intake tagged ad-hoc. Built from the SAME band minter + `reconstructDoseHistory` the compliance % consumes, so the history view and the rate can never disagree. v1.32.8 (iOS #64) surfaces `intake.source` so a client can label how each dose was recorded. Ownership-scoped; rate-limited 60/min/user; reads only non-deleted rows. Auth via cookie or Bearer.", + requestParams: { + path: z.object({ id: z.string() }), + query: doseHistoryQuery, + }, + responses: { + "200": { + description: "Dose-history ledger.", + content: { + "application/json": { + schema: dataEnvelope( + doseHistoryResponse, + "GetDoseHistoryResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/medications/{id}/schedule-revisions": { get: { tags: ["Medications"], diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 6e038efaf..c2f5d6101 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -768,6 +768,101 @@ export const medicationExtractRequest = z "Free-text medication description payload. The route runs the text through the Coach provider chain and returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user; budget-gated against the daily Coach token ceiling.", }); +// v1.15.18 — traceable dose-history read (the "Verlauf" tab ledger). Additive +// GET; iOS-consumed. Transcribed from the handler's `SerializedDoseHistoryRow` +// / response in `src/app/api/medications/[id]/dose-history/route.ts`. v1.32.8 +// (iOS #64) adds `intake.source` so a client can label how each dose was +// recorded. +export const doseHistoryQuery = z.object({ + from: z.iso + .datetime({ offset: true }) + .optional() + .describe( + "Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a 366-day span floor.", + ), + to: z.iso + .datetime({ offset: true }) + .optional() + .describe("Window end (inclusive). Defaults to now. Must be ≥ `from`."), +}); + +const doseHistoryRow = z + .object({ + kind: z + .enum(["slot", "ad_hoc"]) + .describe( + "`slot` — a scheduled dose window; `ad_hoc` — a standalone off-schedule intake.", + ), + at: z.iso + .datetime({ offset: true }) + .describe("The slot anchor instant, or the ad-hoc take's own time."), + timeOfDay: z + .string() + .nullable() + .describe("The slot's `HH:mm` label, or null for an ad-hoc row."), + status: z.enum([ + "taken_on_time", + "taken_late", + "skipped", + "missed", + "upcoming", + "ad_hoc", + ]), + pinned: z + .boolean() + .optional() + .describe( + "Present and true when the row is served by a deliberate user pin ('zugeordnet').", + ), + nearestSlot: z + .object({ + at: z.iso.datetime({ offset: true }), + timeOfDay: z.string(), + filled: z.boolean(), + }) + .optional() + .describe( + "Due-context for an ad-hoc take: the nearest slot it could belong to (preferring an unserved one). `filled` false means the slot can still be offered for pinning.", + ), + intake: z + .object({ + id: z.string().nullable(), + scheduledFor: z.iso.datetime({ offset: true }), + takenAt: z.iso.datetime({ offset: true }).nullable(), + skipped: z.boolean(), + autoMissed: z.boolean(), + doseTaken: z + .string() + .nullable() + .describe("Per-intake dose override; null = configured dose."), + source: z + .enum(["WEB", "API", "REMINDER", "IMPORT", "APPLE_HEALTH"]) + .nullable() + .describe( + "v1.32.8 (iOS #64) — how the dose was recorded: `WEB` (browser), `API` (Bearer / native app), `REMINDER` (the medication reminder worker), `IMPORT` (CSV importer), `APPLE_HEALTH` (the HealthKit dose-event mirror). Null on legacy rows written before the column carried a value. Derived server-side from the write transport, never client-asserted.", + ), + }) + .nullable() + .describe("The intake attributed to this row, if any."), + }) + .meta({ id: "DoseHistoryRow" }); + +export const doseHistoryResponse = z + .object({ + from: z.iso.datetime({ offset: true }), + to: z.iso.datetime({ offset: true }), + family: z + .enum(["daily", "weekly", "one_shot", "none"]) + .describe("Cadence family the window's slots were minted under."), + hasExpectedSlots: z.boolean(), + rows: z.array(doseHistoryRow), + }) + .meta({ + id: "DoseHistoryResponse", + description: + "Per-slot dose ledger over [from, to]: every expected slot with a status plus every off-schedule intake tagged ad-hoc. Built from the same band minter + `reconstructDoseHistory` the compliance % consumes, so the history view and the rate never contradict each other.", + }); + medicationExtractionSchema.meta({ id: "MedicationExtractionResult", description: