mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
34 files changed
+1137
-65
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user