diff --git a/CHANGELOG.md b/CHANGELOG.md index bf89ffce3..f703f8600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +## [1.32.8] — 2026-07-23 + +Health records written through the app now carry where they came from, and a +HealthKit sync can say what set it off. + +- **A dose logged from the app is recorded as an app entry.** Every intake used + to be stored as a web entry no matter how it arrived. The server now reads the + transport it authenticated the call with and tags the dose to match, so a dose + logged in the native app reads as an app entry while one logged in the browser + stays a web entry. A reminder confirmation, an import, and an Apple Health + mirror keep their own labels. The value is derived on the server and can never + be set from the request body. +- **The dose history shows how each dose was recorded.** The per-dose history + returns a source label on every intake, so a client can show at a glance + whether a dose came from the web, the app, a reminder, an import, or an Apple + Health mirror. Rows written before the label existed report as unknown rather + than guessing. +- **A HealthKit sync can report what woke it.** The batch upload accepts an + optional trigger tag reading foreground, background, or push. It is recorded + for diagnostics only and never changes which readings are stored or how they + are deduplicated, so sending it or leaving it off produces the same result. + ## [1.32.7] — 2026-07-23 The Coach output guard now reads numbers the way you actually write them, so a diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 12a39b1c2..ba6cce1d8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.7 + version: 1.32.8 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -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/package.json b/package.json index ce6797dda..5587af24b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.7", + "version": "1.32.8", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index c62403b05..3f14e5752 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.7"; + /* @sw-version-fallback */ "v1.32.8"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/migration-number-uniqueness.test.ts b/src/__tests__/migration-number-uniqueness.test.ts new file mode 100644 index 000000000..de7ea3c53 --- /dev/null +++ b/src/__tests__/migration-number-uniqueness.test.ts @@ -0,0 +1,69 @@ +/** + * Structural guard — every Prisma migration owns a unique numeric prefix. + * + * Migrations are named `NNNN_description`, and the four-digit prefix is the + * ordering key. When two short-lived branches each grab the next free number + * and both merge, the tree ends up with two `0268_*` directories. Prisma + * applies them in lexical order, but the collision is a silent + * merge-time hazard: a reviewer reading `git log` sees one number twice, and a + * later renumber has to touch history. This test fails the build the moment a + * prefix repeats, so the collision is caught at PR CI rather than after merge. + */ +import { readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +import { describe, it, expect } from "vitest"; + +const MIGRATIONS_DIR = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../prisma/migrations", +); + +const NUMBERED_MIGRATION = /^(\d+)_/; + +/** + * One grandfathered collision that predates this guard. `0025_refresh_tokens` + * and `0025_user_locale_drift_fix` are both long applied on every deployed + * database — a migration directory name is the primary key in the + * `_prisma_migrations` ledger, so renaming either would orphan the applied + * record and force a re-run on every tenant. It is frozen as history; the + * guard grandfathers exactly this prefix and refuses every NEW one. + */ +const GRANDFATHERED_PREFIXES = new Set(["0025"]); + +describe("prisma migrations — numeric prefixes are unique", () => { + it("has no two migration directories sharing a numeric prefix", () => { + const prefixes = new Map(); + for (const entry of readdirSync(MIGRATIONS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const match = NUMBERED_MIGRATION.exec(entry.name); + if (!match) continue; + const prefix = match[1]; + const bucket = prefixes.get(prefix) ?? []; + bucket.push(entry.name); + prefixes.set(prefix, bucket); + } + + const collisions = [...prefixes.entries()].filter( + ([prefix, names]) => + names.length > 1 && !GRANDFATHERED_PREFIXES.has(prefix), + ); + + expect( + collisions, + collisions.length > 0 + ? `Duplicate migration prefixes:\n${collisions + .map(([prefix, names]) => ` ${prefix}: ${names.join(", ")}`) + .join("\n")}` + : undefined, + ).toEqual([]); + }); + + it("finds the migration set (guards against a broken directory path)", () => { + const numbered = readdirSync(MIGRATIONS_DIR, { + withFileTypes: true, + }).filter((e) => e.isDirectory() && NUMBERED_MIGRATION.test(e.name)); + expect(numbered.length).toBeGreaterThan(0); + }); +}); 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/__tests__/sync-trigger.test.ts b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts new file mode 100644 index 000000000..bd44f7946 --- /dev/null +++ b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts @@ -0,0 +1,303 @@ +/** + * v1.32.8 (iOS #66) — the optional per-request `syncTrigger` on the HealthKit + * batch ingest. + * + * It is DIAGNOSTIC-ONLY: recorded on the `measurement.batch.ingest` wide event + * so an operator can see, per batch, what woke the client (foreground app open, + * background refresh, or a push), and NOTHING else. These pin that contract: + * + * - a batch carrying `syncTrigger:"background"` is accepted and the value + * rides the ingest annotation; + * - a batch WITHOUT it is accepted and the annotation carries `null`; + * - an invalid value is a 422 (closed enum); + * - the field never changes which rows are inserted / updated — the per-row + * outcomes are byte-identical with and without it, so it cannot leak into + * dedup, attribution, or storage. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ annotate: vi.fn() })); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { update: vi.fn() }, + measurement: { + findMany: vi.fn(), + createManyAndReturn: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(async (fn: unknown) => { + if (typeof fn === "function") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn as any)(prisma as unknown as { measurement: unknown }); + } + }), + }, +})); + +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: mocks.annotate }; +}); + +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: async ( + tx: { + measurement: { + findMany: (args: unknown) => Promise< + Array<{ + id?: string; + type?: string; + source?: string; + externalId?: string | null; + }> + >; + createManyAndReturn: (args: { + data: Record; + }) => Promise>>; + updateMany: (args: { + where: Record; + data: Record; + }) => Promise; + }; + }, + input: Record & { + userId: string; + type: string; + source: string; + externalId: string; + }, + options: { exactExternalMatch?: "update" | "duplicate" } = {}, + ) => { + const existing = await tx.measurement.findMany({}); + const exact = existing.find( + (row: { type?: string; source?: string; externalId?: string | null }) => + row.type === input.type && + row.source === input.source && + row.externalId === input.externalId, + ); + if (exact && options.exactExternalMatch !== "update") { + return { status: "duplicate", row: exact }; + } + if (exact) { + await tx.measurement.updateMany({ + where: { id: exact.id }, + data: { ...input, deletedAt: null }, + }); + return { status: "updated", row: { ...exact, ...input } }; + } + const [inserted] = await tx.measurement.createManyAndReturn({ + data: input, + }); + if (inserted) return { status: "inserted", row: inserted }; + return { status: "duplicate" }; + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/jobs/pr-detection", () => ({ + enqueuePrDetection: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/reminder-satisfy", () => ({ + enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), + collapseToTypeDayKeys: vi.fn(() => []), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const SAMPLE_ID = "C0FFEE00-0000-4000-8000-000000000000"; +const STATS_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-06-21"; + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function sampleEntry(externalId: string, value: number) { + return { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value, + unit: "count/min", + startDate: "2026-06-21T14:00:00.000Z", + endDate: "2026-06-21T14:00:01.000Z", + externalId, + }; +} + +function stepsEntry(externalId: string, value: number) { + return { + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + value, + unit: "count", + startDate: "2026-06-21T00:00:00.000Z", + endDate: "2026-06-21T23:59:59.000Z", + externalId, + }; +} + +function ingestMeta(): Record { + const call = mocks.annotate.mock.calls.find( + ([arg]) => + (arg as { action?: { name?: string } })?.action?.name === + "measurement.batch.ingest", + ); + return (call?.[0] as { meta: Record }).meta; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 60, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation((async ( + args: unknown, + ) => { + const { data } = args as { + data: + | { type: unknown; source: string; externalId: string | null } + | Array<{ type: unknown; source: string; externalId: string | null }>; + }; + const rows = Array.isArray(data) ? data : [data]; + return rows.map(({ type, source, externalId }) => ({ + type, + source, + externalId, + })); + }) as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 }); +}); + +describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", () => { + it("records `syncTrigger` on the ingest annotation when the client sends it", async () => { + const res = await POST( + makeRequest({ + entries: [sampleEntry(SAMPLE_ID, 64)], + syncTrigger: "background", + }), + ); + expect(res.status).toBe(200); + expect(ingestMeta().syncTrigger).toBe("background"); + }); + + it("accepts a batch WITHOUT `syncTrigger` and annotates null", async () => { + const res = await POST( + makeRequest({ entries: [sampleEntry(SAMPLE_ID, 64)] }), + ); + expect(res.status).toBe(200); + expect(ingestMeta().syncTrigger).toBe(null); + }); + + it("rejects an invalid `syncTrigger` value with 422 (closed enum)", async () => { + const res = await POST( + makeRequest({ + entries: [sampleEntry(SAMPLE_ID, 64)], + syncTrigger: "wifi", + }), + ); + expect(res.status).toBe(422); + }); + + it("does not change which rows insert / update with vs without the field", async () => { + // A pre-existing per-day steps row makes the re-post an overwrite; a fresh + // sample row inserts. The per-row outcome must be identical regardless of + // whether `syncTrigger` rides the request. + const seedExisting = () => + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "m1", + type: "ACTIVITY_STEPS", + source: "APPLE_HEALTH", + externalId: STATS_ID, + }, + ] as never); + + const entries = [sampleEntry(SAMPLE_ID, 64), stepsEntry(STATS_ID, 5000)]; + + seedExisting(); + const withoutRes = await POST(makeRequest({ entries })); + expect(withoutRes.status).toBe(200); + const withoutBody = (await withoutRes.json()).data; + + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 60, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation( + (async (args: unknown) => { + const { data } = args as { data: unknown }; + const rows = Array.isArray(data) ? data : [data]; + return rows.map((r) => { + const row = r as { + type: unknown; + source: string; + externalId: string | null; + }; + return { + type: row.type, + source: row.source, + externalId: row.externalId, + }; + }); + }) as never, + ); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 }); + seedExisting(); + const withRes = await POST(makeRequest({ entries, syncTrigger: "push" })); + expect(withRes.status).toBe(200); + const withBody = (await withRes.json()).data; + + // The row-level outcome (what inserted, what updated, per-entry statuses) + // is byte-identical; only the diagnostic annotation differs. + expect(withBody.inserted).toBe(withoutBody.inserted); + expect(withBody.updated).toBe(withoutBody.updated); + expect(withBody.duplicates).toBe(withoutBody.duplicates); + expect(withBody.entries).toEqual(withoutBody.entries); + expect(withoutBody.updated).toBe(1); + expect(withoutBody.inserted).toBe(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/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts index 20640c271..ccc4f707d 100644 --- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -201,6 +201,56 @@ describe("GET /api/medications/[id]/dose-history", () => { expect(adHoc?.intake?.id).toBe("evt-adhoc"); }); + it("returns each intake's write provenance in `source` (iOS #64)", async () => { + const from = at(DAY, 0, 0).toISOString(); + const to = at(DAY, 23, 59).toISOString(); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + // a native-app take carries API provenance + { + id: "evt-morning", + scheduledFor: at(DAY, 7, 0), + takenAt: at(DAY, 7, 5), + skipped: false, + autoMissed: false, + source: "API", + }, + // a legacy row written before the column carried a value → null + { + id: "evt-adhoc", + scheduledFor: at(DAY, 11, 29), + takenAt: at(DAY, 11, 29), + skipped: false, + autoMissed: false, + source: null, + }, + ] as never); + + // The route must SELECT `source` from Prisma, or it could never surface it. + const res = await GET( + getReq(`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`), + ROUTE_PARAMS, + ); + expect(res.status).toBe(200); + const select = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0][0]?.select as Record | undefined; + expect(select?.source).toBe(true); + + const json = await res.json(); + const rows = json.data.rows as Array<{ + kind: string; + timeOfDay: string | null; + intake: { id: string | null; source: string | null } | null; + }>; + + const morning = rows.find((r) => r.timeOfDay === "07:00"); + expect(morning?.intake?.id).toBe("evt-morning"); + expect(morning?.intake?.source).toBe("API"); + + const adHoc = rows.find((r) => r.kind === "ad_hoc"); + expect(adHoc?.intake?.id).toBe("evt-adhoc"); + expect(adHoc?.intake?.source).toBe(null); + }); + it("422 on a reversed from/to window", async () => { const from = at(DAY, 12, 0).toISOString(); const to = at(DAY, 6, 0).toISOString(); 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/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts new file mode 100644 index 000000000..3b4ebe141 --- /dev/null +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -0,0 +1,203 @@ +/** + * v1.32.8 (iOS #64) — write-provenance of a medication intake is derived from + * the TRANSPORT, never from the request body. + * + * - a cookie (browser) POST records `source: "WEB"`; + * - a Bearer (native app) POST records `source: "API"`; + * - a body that tries to set `source` is ignored (no mass assignment); + * - the producer-owned values (`REMINDER` / `IMPORT` / `APPLE_HEALTH`) are + * minted by OTHER routes and are unreachable here, so this route can only + * ever stamp WEB / API — the cookie-vs-Bearer split is the whole surface. + * + * These run the REAL `apiHandler` + `requireAuth` (only `getSession`, + * `next/headers`, and the Bearer resolver are mocked) so the auth-method + * derivation that a unit mock would paper over is exercised end to end. The + * medication has no schedules, so a taken write lands on the standalone + * (ad-hoc) create path where `source` is stamped directly. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + medicationIntakeEvent: { + findMany: vi.fn(), + findFirst: vi.fn(), + count: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + auditLog: { create: vi.fn() }, + $transaction: vi.fn(), + }, +})); + +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); + +// The medication carries no schedules, so a taken write resolves to no +// canonical slot and lands on the standalone create path. Stub the resolvers +// so the route deterministically takes that branch regardless of the fixtures. +vi.mock("@/lib/medications/scheduling/slot-upsert", () => ({ + resolveSlotForWriteByBand: vi.fn(async () => ({ slotInstant: null })), + resolveSlotInstantForWrite: vi.fn(async () => null), + resolveForcedSlotForWrite: vi.fn(async () => null), + findPinConflict: vi.fn(async () => false), + mayConvergeOntoSuppliedSlot: vi.fn(() => true), + applyCanonicalSlotWrite: vi.fn(), +})); + +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/medications/lifecycle", () => ({ + reconcileOneShotState: vi.fn().mockResolvedValue("noop"), +})); + +vi.mock("@/lib/medications/route-guards", () => ({ + assertMedicationOwnership: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); + +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// Default to no Authorization header; the Bearer tests override this. +const headerStore = { authorization: null as string | null }; +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ + get: (name: string) => + name.toLowerCase() === "authorization" ? headerStore.authorization : null, + })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { resolveBearerToken } from "@/lib/auth/bearer"; + +const USER = { + id: "user-1", + username: "testuser", + role: "USER" as const, + timezone: "Europe/Berlin", +}; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: USER, +}; + +const MED_OK = { id: "med-1", userId: "user-1" }; +const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) }; + +function postReq(body: Record): NextRequest { + return new NextRequest("http://localhost/api/medications/med-1/intake", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** The `source` value stamped on the freshly-created standalone intake row. */ +function createdSource(): unknown { + const call = vi.mocked(prisma.medicationIntakeEvent.create).mock.calls[0]; + return (call?.[0] as { data?: { source?: unknown } } | undefined)?.data + ?.source; +} + +beforeEach(() => { + vi.resetAllMocks(); + headerStore.authorization = null; + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // The standalone branch wraps the create in a `$transaction([...])`. The + // create mock records the `data` it was called with; the transaction + // resolves to the created row so the rest of the handler proceeds. + vi.mocked(prisma.medicationIntakeEvent.create).mockReturnValue({ + id: "evt-1", + takenAt: new Date(), + scheduledFor: new Date(), + } as never); + vi.mocked(prisma.$transaction).mockResolvedValue([ + { id: "evt-1", takenAt: new Date(), scheduledFor: new Date() }, + ] as never); +}); + +describe("POST intake — transport-derived source (iOS #64)", () => { + it("stamps WEB for a cookie (browser) session", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const res = await POST(postReq({}), ROUTE_PARAMS); + expect(res.status).toBe(201); + expect(createdSource()).toBe("WEB"); + }); + + it("stamps API for a Bearer (native app) token", async () => { + vi.mocked(getSession).mockResolvedValue(null as never); + headerStore.authorization = "Bearer hlk_test-token"; + vi.mocked(resolveBearerToken).mockResolvedValue({ + user: USER as never, + tokenId: "tok-1", + permissions: ["*"], + expiresAt: new Date(Date.now() + 3_600_000), + }); + + const res = await POST(postReq({}), ROUTE_PARAMS); + expect(res.status).toBe(201); + expect(createdSource()).toBe("API"); + }); + + it("ignores a client-supplied `source` in the body (no mass assignment)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + // A cookie caller forging `source: "APPLE_HEALTH"` must still record WEB — + // the value is derived from the transport, never read from the body. + const res = await POST(postReq({ source: "APPLE_HEALTH" }), ROUTE_PARAMS); + expect(res.status).toBe(201); + expect(createdSource()).toBe("WEB"); + }); +}); 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/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index ca2ec4e7a..8bd2587af 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -32,6 +32,7 @@ import { recomputeMedicationComplianceForDay, } from "@/lib/rollups/medication-compliance-rollups"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { NextRequest } from "next/server"; @@ -39,9 +40,7 @@ type RouteParams = { params: Promise<{ id: string }> }; /** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */ function hhmmToMinutes(value: string): number { - const [h, m] = value.split(":").map(Number); - if (!Number.isFinite(h) || !Number.isFinite(m)) return 0; - return h * 60 + m; + return hhmmToMinutesOrNull(value) ?? 0; } /** diff --git a/src/components/medications/scheduling/dose-window.ts b/src/components/medications/scheduling/dose-window.ts index c6caf558b..a468feb1b 100644 --- a/src/components/medications/scheduling/dose-window.ts +++ b/src/components/medications/scheduling/dose-window.ts @@ -21,6 +21,11 @@ */ import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; +import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm"; + +// Re-exported so the existing `dose-window` consumers (and its unit suite) +// keep their import site while the implementation lives in the shared util. +export { hhmmToMinutes }; /** One persisted explicit window. Matches `doseWindowEntrySchema`. */ export interface DoseWindowEntry { @@ -40,12 +45,6 @@ export type DoseWindowScale = "intraday" | "dayScale"; const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; -/** Minutes-since-midnight for a regex-valid HH:mm. */ -export function hhmmToMinutes(hhmm: string): number { - const [h, m] = hhmm.split(":"); - return Number(h) * 60 + Number(m); -} - /** Wrap minutes-since-midnight back to HH:mm (clamps into the day, 24h wrap). */ export function minutesToHhmm(total: number): string { const wrapped = ((total % 1440) + 1440) % 1440; diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts index 5faecb60d..0611a6854 100644 --- a/src/lib/__tests__/require-auth-bearer.test.ts +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -390,7 +390,8 @@ describe("requireAuth — cookie path remains intact", () => { const ctx = await requireAuth("medication:ingest"); - expect(ctx).toEqual(cookieSession); + // The cookie path carries the session payload plus the derived transport. + expect(ctx).toEqual({ ...cookieSession, authMethod: "cookie" }); // Cookie short-circuits before any token logic runs. expect(prisma.apiToken.findUnique).not.toHaveBeenCalled(); expect(hashToken).not.toHaveBeenCalled(); 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/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 6cfa837be..d4d6bf0cf 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -46,6 +46,7 @@ import { occurrencesBetween, } from "@/lib/medications/scheduling/recurrence"; import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; +import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { startOfLocalDayInTz } from "@/lib/tz/local-day"; @@ -325,13 +326,6 @@ function applyTime(day: Date, hhmm: string, tz: string | undefined): Date { } /** Parse "HH:mm" to minutes-since-midnight, or null when malformed. */ -function hhmmToMinutes(hhmm: string): number | null { - const [hStr, mStr] = hhmm.split(":"); - const h = Number(hStr); - const m = Number(mStr); - if (!Number.isFinite(h) || !Number.isFinite(m)) return null; - return h * 60 + m; -} /** Snap a Date down to the user-local midnight. */ export function startOfLocalDay(d: Date, tz: string | undefined): Date { @@ -504,8 +498,8 @@ export function expandScheduleSlots( // the windowStart..windowEnd span so the pairing radius and the // chart cell keep their existing shape; an overnight window pushes // the end to the next day. - const startMin = hhmmToMinutes(schedule.windowStart); - const endMin = hhmmToMinutes(schedule.windowEnd); + const startMin = hhmmToMinutesOrNull(schedule.windowStart); + const endMin = hhmmToMinutesOrNull(schedule.windowEnd); let spanMs = DAY_MS; if (startMin !== null && endMin !== null) { let span = endMin - startMin; 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/medications/scheduling/hhmm.ts b/src/lib/medications/scheduling/hhmm.ts new file mode 100644 index 000000000..c2aeaf415 --- /dev/null +++ b/src/lib/medications/scheduling/hhmm.ts @@ -0,0 +1,38 @@ +/** + * Shared `HH:mm` → minutes-since-midnight helpers. + * + * Consolidates the copies that had accumulated across the medication + * scheduling surfaces (the window validator, the dose-window geometry, the + * per-medication schedule reconciler, the slot-instant resolver, the + * recurrence engine, the cadence walker, and the worker helpers). Two + * variants, matching the two behaviours those copies actually relied on: + * + * - `hhmmToMinutes` assumes the literal was already regex-validated (the + * Zod window schema, a persisted schedule row) and does the arithmetic + * directly — a malformed literal yields `NaN`, which never reaches these + * callers; + * - `hhmmToMinutesOrNull` handles unvalidated input and returns `null` when + * the literal is not a well-formed numeric `HH:mm`, so a caller can decide + * the fallback (skip the time, treat as 0, …) explicitly. + * + * Pure string arithmetic, no imports — safe to pull into both server and + * client bundles. + */ + +/** Minutes-since-midnight for an already-validated `HH:mm` literal. */ +export function hhmmToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":"); + return Number(h) * 60 + Number(m); +} + +/** + * Minutes-since-midnight, or `null` when the literal is not a well-formed + * numeric `HH:mm`. Use on any path that handles unvalidated schedule input. + */ +export function hhmmToMinutesOrNull(hhmm: string): number | null { + const [hStr, mStr] = hhmm.split(":"); + const h = Number(hStr); + const m = Number(mStr); + if (!Number.isFinite(h) || !Number.isFinite(m)) return null; + return h * 60 + m; +} diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 5a6b9ae8b..f05322d56 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -60,6 +60,7 @@ import { annotate } from "@/lib/logging/context"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; /** * v1.7.0 — schedule-type discriminator on the canonical schedule. @@ -823,8 +824,8 @@ function graceWindowMs(schedule: CanonicalSchedule): number { if (schedule.reminderGraceMinutes !== null) { return schedule.reminderGraceMinutes * ONE_MINUTE_MS; } - const startMin = hhmmToMinutes(schedule.windowStart); - const endMin = hhmmToMinutes(schedule.windowEnd); + const startMin = hhmmToMinutesOrNull(schedule.windowStart); + const endMin = hhmmToMinutesOrNull(schedule.windowEnd); if (startMin === null || endMin === null) { return DEFAULT_GRACE_MINUTES * ONE_MINUTE_MS; } @@ -834,14 +835,6 @@ function graceWindowMs(schedule: CanonicalSchedule): number { return span * ONE_MINUTE_MS; } -function hhmmToMinutes(hhmm: string): number | null { - const [hStr, mStr] = hhmm.split(":"); - const h = Number(hStr); - const m = Number(mStr); - if (!Number.isFinite(h) || !Number.isFinite(m)) return null; - return h * 60 + m; -} - // ──────────────────────────────────────────────────────────────────── // UTC date helpers (RRULE day-anchor + endsOn cap arithmetic) // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/medications/scheduling/resolve-slot-instant.ts b/src/lib/medications/scheduling/resolve-slot-instant.ts index d53fa52e2..56273fbd8 100644 --- a/src/lib/medications/scheduling/resolve-slot-instant.ts +++ b/src/lib/medications/scheduling/resolve-slot-instant.ts @@ -33,6 +33,7 @@ import { type CanonicalSchedule, type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; +import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; import { localHmAsUtc } from "@/lib/tz/local-day"; import { wallClockInTz } from "@/lib/tz/wall-clock"; @@ -238,7 +239,7 @@ function canonicalSlotInstant( timeOfDay: string, userTz: string, ): Date { - const minutes = hhmmToMinutes(timeOfDay); + const minutes = hhmmToMinutesOrNull(timeOfDay); if (minutes === null) return occAt; const h = Math.floor(minutes / 60); const m = minutes % 60; @@ -267,8 +268,8 @@ function canonicalSlotInstant( * half-span behaviour unchanged. */ function snapToleranceMs(schedule: CanonicalSchedule): number { - const startMin = hhmmToMinutes(schedule.windowStart); - const endMin = hhmmToMinutes(schedule.windowEnd); + const startMin = hhmmToMinutesOrNull(schedule.windowStart); + const endMin = hhmmToMinutesOrNull(schedule.windowEnd); let halfSpanMs: number; if (startMin === null || endMin === null) { halfSpanMs = DEFAULT_TOLERANCE_MS; @@ -281,7 +282,7 @@ function snapToleranceMs(schedule: CanonicalSchedule): number { let toleranceMs = halfSpanMs; const slots = effectiveTimesOfDay(schedule) - .map(hhmmToMinutes) + .map(hhmmToMinutesOrNull) .filter((m): m is number => m !== null) .sort((a, b) => a - b); if (slots.length > 1) { @@ -325,7 +326,7 @@ function graceToleranceMs(schedule: CanonicalSchedule): number { : ONE_MINUTE_MS; const slots = effectiveTimesOfDay(schedule) - .map(hhmmToMinutes) + .map(hhmmToMinutesOrNull) .filter((m): m is number => m !== null) .sort((a, b) => a - b); if (slots.length > 1) { @@ -353,11 +354,3 @@ function effectiveTimesOfDay(schedule: CanonicalSchedule): string[] { ? schedule.timesOfDay : [schedule.windowStart]; } - -function hhmmToMinutes(hhmm: string): number | null { - const [hStr, mStr] = hhmm.split(":"); - const h = Number(hStr); - const m = Number(mStr); - if (!Number.isFinite(h) || !Number.isFinite(m)) return null; - return h * 60 + m; -} diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts index fa66b78ed..92e18ffa9 100644 --- a/src/lib/medications/scheduling/worker-helpers.ts +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -23,6 +23,7 @@ import { type ScheduleType, occurrencesBetween, } from "@/lib/medications/scheduling/recurrence"; +import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm"; /** * Minimal Prisma-shape projection used by the worker. Mirrors the @@ -122,11 +123,6 @@ export function normaliseDoseWindows(raw: unknown): DoseWindowEntry[] | null { return out.length > 0 ? out : null; } -function hhmmToMinutes(hhmm: string): number { - const [h, m] = hhmm.split(":"); - return Number(h) * 60 + Number(m); -} - /** Build the canonical engine context from worker-loop state. */ export function buildRecurrenceContext(input: { medication: WorkerMedicationRow; 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: diff --git a/src/lib/validations/medication/base.ts b/src/lib/validations/medication/base.ts index 7a6709c5c..b1da554a4 100644 --- a/src/lib/validations/medication/base.ts +++ b/src/lib/validations/medication/base.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; +import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm"; /** * v1.8.5 — the eight injection-site enum values, mirrored from the @@ -190,12 +191,6 @@ export const doseWindowEntrySchema = z "One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc.", }); -/** Minutes-since-midnight for an `HH:mm` literal (already regex-validated). */ -export function hhmmToMinutes(hhmm: string): number { - const [h, m] = hhmm.split(":"); - return Number(h) * 60 + Number(m); -} - /** * v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path * (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the