Merge pull request #604 from MBombeck/release/v1.32.8

release/v1.32.8 — Provenance: transport-derived intake source + diagnostic sync trigger
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-23 21:52:22 +02:00
commit 2dc3c012d1
34 files changed
+1137 -65

No files matched your search

+22
View File
@@ -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
+221 -1
View File
@@ -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:
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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<string, string[]>();
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);
});
});
@@ -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",
@@ -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<typeof import("@/lib/logging/context")>();
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<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
updateMany: (args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
};
},
input: Record<string, unknown> & {
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<string, unknown> {
const call = mocks.annotate.mock.calls.find(
([arg]) =>
(arg as { action?: { name?: string } })?.action?.name ===
"measurement.batch.ingest",
);
return (call?.[0] as { meta: Record<string, unknown> }).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);
});
});
+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,
},
});
@@ -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<string, unknown> | 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();
@@ -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,
}));
@@ -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:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...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<string, unknown>): 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");
});
});
+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 && {
+2 -3
View File
@@ -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;
}
/**
@@ -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;
@@ -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();
+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 };
}
/**
+3 -9
View File
@@ -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;
@@ -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 =
+38
View File
@@ -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;
}
+3 -10
View File
@@ -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)
// ────────────────────────────────────────────────────────────────────
@@ -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;
}
@@ -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;
+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:
+1 -6
View File
@@ -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