mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(medications,measurements): derive write provenance from transport and surface it
Root-cause the intake source attribution and give the HealthKit batch a
diagnostic sync-trigger, one attribution surface for both.
AuthContext now carries a read-only authMethod ("cookie" | "bearer"),
derived from the same branch that sets the wide event's auth_method. The
medication-intake route maps it to the stored IntakeSource: a Bearer
(native) call records API, a cookie (browser) call records WEB. The other
producer values are untouched — a converge onto a pending reminder row
updates through applyCanonicalSlotWrite, which never rewrites source, so
REMINDER / IMPORT / APPLE_HEALTH provenance is preserved. The value is
never client-asserted, the same posture as userId being narrowed from auth.
The dose-history ledger now selects and returns source per intake row so a
client can label how each dose was recorded. The endpoint gains its first
OpenAPI entry (path plus response schema) so the field lands in the
iOS-consumed contract.
The measurements batch accepts an optional per-request syncTrigger
(foreground | background | push). It is diagnostic-only: recorded on the
measurement.batch.ingest wide event and nothing else — never persisted,
never part of the dedup key, never an input to attribution or how a sample
is stored. Sending it or omitting it produces byte-identical row outcomes.
Regenerated docs/api/openapi.yaml.
This commit is contained in:
1 parent
d321391024
commit
27b548ab13
18 files changed
+426
-9
No files matched your search
@@ -4090,6 +4090,56 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/medications/{id}/dose-history:
|
||||
get:
|
||||
tags:
|
||||
- Medications
|
||||
summary: Per-slot dose-history ledger (Verlauf tab)
|
||||
description: "Returns the full per-slot ledger for the medication over [from, to]: every expected slot with a status
|
||||
(taken on-time / taken late / skipped / missed / upcoming) plus every off-schedule intake tagged ad-hoc. Built
|
||||
from the SAME band minter + `reconstructDoseHistory` the compliance % consumes, so the history view and the rate
|
||||
can never disagree. v1.32.8 (iOS #64) surfaces `intake.source` so a client can label how each dose was recorded.
|
||||
Ownership-scoped; rate-limited 60/min/user; reads only non-deleted rows. Auth via cookie or Bearer."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
description: Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a
|
||||
366-day span floor.
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a
|
||||
366-day span floor.
|
||||
- in: query
|
||||
name: to
|
||||
schema:
|
||||
description: Window end (inclusive). Defaults to now. Must be ≥ `from`.
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: Window end (inclusive). Defaults to now. Must be ≥ `from`.
|
||||
responses:
|
||||
"200":
|
||||
description: Dose-history ledger.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetDoseHistoryResponse"
|
||||
"401": *a1
|
||||
"404":
|
||||
description: Medication not found (or owned by another user).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/medications/{id}/schedule-revisions:
|
||||
get:
|
||||
tags:
|
||||
@@ -11828,6 +11878,16 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AppleHealthBatchEntry"
|
||||
syncTrigger:
|
||||
description: "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake)
|
||||
so an operator can see, per batch, which trigger produced it. Recorded on the ingest wide event and nowhere
|
||||
else — it does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible:
|
||||
pre-#66 clients omit it."
|
||||
type: string
|
||||
enum:
|
||||
- foreground
|
||||
- background
|
||||
- push
|
||||
required:
|
||||
- entries
|
||||
description: Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`.
|
||||
@@ -20859,6 +20919,166 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
GetDoseHistoryResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/DoseHistoryResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
DoseHistoryResponse:
|
||||
type: object
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
to:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
family:
|
||||
type: string
|
||||
enum:
|
||||
- daily
|
||||
- weekly
|
||||
- one_shot
|
||||
- none
|
||||
description: Cadence family the window's slots were minted under.
|
||||
hasExpectedSlots:
|
||||
type: boolean
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DoseHistoryRow"
|
||||
required:
|
||||
- from
|
||||
- to
|
||||
- family
|
||||
- hasExpectedSlots
|
||||
- rows
|
||||
additionalProperties: false
|
||||
description: "Per-slot dose ledger over [from, to]: every expected slot with a status plus every off-schedule intake
|
||||
tagged ad-hoc. Built from the same band minter + `reconstructDoseHistory` the compliance % consumes, so the
|
||||
history view and the rate never contradict each other."
|
||||
DoseHistoryRow:
|
||||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- slot
|
||||
- ad_hoc
|
||||
description: "`slot` — a scheduled dose window; `ad_hoc` — a standalone off-schedule intake."
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: The slot anchor instant, or the ad-hoc take's own time.
|
||||
timeOfDay:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: The slot's `HH:mm` label, or null for an ad-hoc row.
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- taken_on_time
|
||||
- taken_late
|
||||
- skipped
|
||||
- missed
|
||||
- upcoming
|
||||
- ad_hoc
|
||||
pinned:
|
||||
description: Present and true when the row is served by a deliberate user pin ('zugeordnet').
|
||||
type: boolean
|
||||
nearestSlot:
|
||||
description: "Due-context for an ad-hoc take: the nearest slot it could belong to (preferring an unserved one). `filled`
|
||||
false means the slot can still be offered for pinning."
|
||||
type: object
|
||||
properties:
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
timeOfDay:
|
||||
type: string
|
||||
filled:
|
||||
type: boolean
|
||||
required:
|
||||
- at
|
||||
- timeOfDay
|
||||
- filled
|
||||
additionalProperties: false
|
||||
intake:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
id:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
scheduledFor:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
takenAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
skipped:
|
||||
type: boolean
|
||||
autoMissed:
|
||||
type: boolean
|
||||
doseTaken:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Per-intake dose override; null = configured dose.
|
||||
source:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- WEB
|
||||
- API
|
||||
- REMINDER
|
||||
- IMPORT
|
||||
- APPLE_HEALTH
|
||||
- type: "null"
|
||||
description: "v1.32.8 (iOS #64) — how the dose was recorded: `WEB` (browser), `API` (Bearer / native app), `REMINDER`
|
||||
(the medication reminder worker), `IMPORT` (CSV importer), `APPLE_HEALTH` (the HealthKit dose-event
|
||||
mirror). Null on legacy rows written before the column carried a value. Derived server-side from the
|
||||
write transport, never client-asserted."
|
||||
required:
|
||||
- id
|
||||
- scheduledFor
|
||||
- takenAt
|
||||
- skipped
|
||||
- autoMissed
|
||||
- doseTaken
|
||||
- source
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: The intake attributed to this row, if any.
|
||||
required:
|
||||
- kind
|
||||
- at
|
||||
- timeOfDay
|
||||
- status
|
||||
- intake
|
||||
additionalProperties: false
|
||||
ListScheduleRevisionsResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -58,6 +58,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
|
||||
@@ -26,6 +26,7 @@ import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer";
|
||||
import type { WideEvent } from "@/lib/logging/types";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
|
||||
@@ -55,6 +55,7 @@ import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
|
||||
@@ -36,6 +36,7 @@ describe("GET /api/admin/users", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRequireAdmin.mockResolvedValue({
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
|
||||
@@ -159,8 +159,18 @@ const batchEntrySchema = z.object({
|
||||
deviceType: deviceTypeEnum.nullable().optional(),
|
||||
});
|
||||
|
||||
// v1.32.8 (iOS #66) — optional per-REQUEST diagnostic tag naming what woke the
|
||||
// client for this sync. Purely observational: it flows into the
|
||||
// `measurement.batch.ingest` wide event and NOTHING else — never persisted,
|
||||
// never part of the dedup key, never an input to attribution or how a sample is
|
||||
// stored. Only the client knows its own wake reason, so it is client-asserted
|
||||
// by necessity; being diagnostic-only, that carries no trust surface. Optional
|
||||
// so every pre-#66 caller stays byte-for-byte unchanged.
|
||||
const syncTriggerEnum = z.enum(["foreground", "background", "push"]);
|
||||
|
||||
const batchPayloadSchema = z.object({
|
||||
entries: z.array(batchEntrySchema).min(1),
|
||||
syncTrigger: syncTriggerEnum.optional(),
|
||||
});
|
||||
|
||||
type BatchEntry = z.infer<typeof batchEntrySchema>;
|
||||
@@ -248,7 +258,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
|
||||
}
|
||||
|
||||
const { entries } = parsed.data;
|
||||
const { entries, syncTrigger } = parsed.data;
|
||||
|
||||
// First pass — map each inbound entry; record skipped entries.
|
||||
type Prepared = {
|
||||
@@ -784,6 +794,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// `skippedByReason` build above.
|
||||
skipped_by_reason: skippedByReason,
|
||||
failed: failedCount,
|
||||
// v1.32.8 (iOS #66) — diagnostic sync-trigger tag (foreground /
|
||||
// background / push), or null when the client did not send one.
|
||||
// Observability only; it never influenced any count above.
|
||||
syncTrigger: syncTrigger ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ interface SerializedDoseHistoryRow {
|
||||
autoMissed: boolean;
|
||||
/** v1.16.4 — per-intake dose override; null = configured dose. */
|
||||
doseTaken: string | null;
|
||||
/** v1.32.8 (iOS #64) — how the dose was recorded; null on legacy rows. */
|
||||
source: "WEB" | "API" | "REMINDER" | "IMPORT" | "APPLE_HEALTH" | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -161,6 +163,9 @@ export const GET = apiHandler(
|
||||
attributionSource: true,
|
||||
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
|
||||
doseTaken: true,
|
||||
// v1.32.8 (iOS #64) — write provenance so the client can label how
|
||||
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
|
||||
source: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -172,6 +177,7 @@ export const GET = apiHandler(
|
||||
autoMissed: e.autoMissed,
|
||||
pinned: e.attributionSource === "USER_PIN",
|
||||
doseTaken: e.doseTaken,
|
||||
source: e.source,
|
||||
}));
|
||||
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
|
||||
@@ -244,6 +250,7 @@ export const GET = apiHandler(
|
||||
autoMissed: e.autoMissed,
|
||||
pinned: e.pinned,
|
||||
doseTaken: e.doseTaken,
|
||||
source: e.source,
|
||||
}));
|
||||
|
||||
const rows = reconstructDoseHistory(bands, historyIntakes, now);
|
||||
@@ -269,6 +276,7 @@ export const GET = apiHandler(
|
||||
skipped: row.intake.skipped,
|
||||
autoMissed: row.intake.autoMissed ?? false,
|
||||
doseTaken: row.intake.doseTaken ?? null,
|
||||
source: row.intake.source ?? null,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
@@ -43,7 +43,16 @@ export const POST = apiHandler(
|
||||
);
|
||||
|
||||
async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
const { user } = await requireAuth();
|
||||
const { user, authMethod } = await requireAuth();
|
||||
// v1.32.8 (iOS #64) — write provenance derived from the transport, never
|
||||
// client-asserted: a Bearer/native call stores `API`, a cookie/browser call
|
||||
// stores `WEB`. The other `IntakeSource` values are producer-owned by OTHER
|
||||
// routes (the Telegram worker mints `REMINDER`, the bulk/import paths mint
|
||||
// `IMPORT` / `APPLE_HEALTH`), and this route only ever CREATES its own rows —
|
||||
// a converge onto a pending `REMINDER` row updates through
|
||||
// `applyCanonicalSlotWrite`, which never rewrites `source`, so that
|
||||
// provenance is preserved.
|
||||
const intakeSource = authMethod === "bearer" ? "API" : "WEB";
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
@@ -316,7 +325,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
createSource: "WEB",
|
||||
createSource: intakeSource,
|
||||
// v1.8.5 — resolved + validated site (null unless a tracking-on
|
||||
// injection taken write supplied an allowed site).
|
||||
injectionSite: resolvedInjectionSite,
|
||||
@@ -382,7 +391,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
createSource: "WEB",
|
||||
createSource: intakeSource,
|
||||
injectionSite: resolvedInjectionSite,
|
||||
attributionSource,
|
||||
doseTaken: resolvedDoseTaken,
|
||||
@@ -409,7 +418,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
scheduledFor: resolvedTakenAt ?? incomingScheduledFor,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
source: "WEB",
|
||||
source: intakeSource,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
// v1.8.5 — site only on a resolved taken-injection write.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
|
||||
+14
-4
@@ -294,6 +294,15 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
export type AuthContext = {
|
||||
session: { id: string; expiresAt: Date };
|
||||
user: User;
|
||||
/**
|
||||
* Transport the caller authenticated with, derived from the SAME branch that
|
||||
* sets the wide event's `auth_method`: the cookie-session path resolves
|
||||
* `"cookie"`, the Bearer-token path resolves `"bearer"`. Read-only and never
|
||||
* client-asserted — it is the transport fact, not a request field. A route
|
||||
* that must record write provenance (e.g. the medication-intake `source`)
|
||||
* maps it server-side, the same posture as `userId` being narrowed from auth.
|
||||
*/
|
||||
readonly authMethod: "cookie" | "bearer";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -326,7 +335,7 @@ export async function requireAuth(
|
||||
auth_method: "session",
|
||||
});
|
||||
}
|
||||
return sessionData;
|
||||
return { ...sessionData, authMethod: "cookie" };
|
||||
}
|
||||
|
||||
// 2. Bearer-token path.
|
||||
@@ -453,6 +462,7 @@ async function authenticateBearer(
|
||||
return {
|
||||
session: { id: tokenId, expiresAt },
|
||||
user,
|
||||
authMethod: "bearer",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -477,7 +487,7 @@ export async function requireAdmin(): Promise<AuthContext> {
|
||||
if (sessionData.user.role !== "ADMIN") {
|
||||
throw new HttpError(403, "Admin access required");
|
||||
}
|
||||
return sessionData;
|
||||
return { ...sessionData, authMethod: "cookie" };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,7 +512,7 @@ export async function requireCookieAuth(): Promise<AuthContext> {
|
||||
auth_method: "session",
|
||||
});
|
||||
}
|
||||
return sessionData;
|
||||
return { ...sessionData, authMethod: "cookie" };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -585,7 +595,7 @@ export async function requireFreshMfa(
|
||||
throw new StepUpRequiredError();
|
||||
}
|
||||
|
||||
return { ...sessionData, mfaVerifiedAt: verifiedAt };
|
||||
return { ...sessionData, authMethod: "cookie", mfaVerifiedAt: verifiedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,15 @@ export interface HistoryIntake {
|
||||
* applies. Pure pass-through for the read ledger (no status impact).
|
||||
*/
|
||||
doseTaken?: string | null;
|
||||
/**
|
||||
* v1.32.8 (iOS #64) — write provenance of the stored intake row: `WEB`
|
||||
* (browser cookie), `API` (Bearer / native app), `REMINDER` (the Telegram
|
||||
* worker), `IMPORT` (CSV importer), `APPLE_HEALTH` (the HealthKit dose-event
|
||||
* mirror). Pure pass-through for the read ledger (no status impact); lets a
|
||||
* client label how each dose was recorded. Optional so legacy callers /
|
||||
* fixtures omit it.
|
||||
*/
|
||||
source?: "WEB" | "API" | "REMINDER" | "IMPORT" | "APPLE_HEALTH";
|
||||
}
|
||||
|
||||
export type DoseHistoryStatus =
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user