feat(medications,measurements): derive write provenance from transport and surface it

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-23 21:15:17 +02:00
1 parent d321391024
commit 27b548ab13
18 files changed
+426 -9

No files matched your search

+220
View File
@@ -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:
@@ -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",
@@ -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,
};
@@ -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,
};
@@ -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,
};
@@ -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",
@@ -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,
};
@@ -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",
@@ -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",
@@ -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",
+15 -1
View File
@@ -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<typeof batchEntrySchema>;
@@ -248,7 +258,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
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<Response> {
// `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,
},
});
@@ -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,
}));
+13 -4
View File
@@ -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 && {
+14 -4
View File
@@ -294,6 +294,15 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
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<AuthContext> {
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<AuthContext> {
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 };
}
/**
@@ -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 =
+11
View File
@@ -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",
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
@@ -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: