chore(release): v1.28.23 — sleep disagreement on the dashboard; Apple Health medication groundwork

The sleep source-discrepancy annotation now reaches the surfaces: SleepNight
carries the MAIN session's annotation (naps never cross-compare), the analytics
slim slice / dashboard snapshot / iOS summary thread it additively, and the
dashboard sleep tile renders the shared discreet marker (extracted from the
sleep panel — one implementation, two surfaces). Served totals unchanged,
pinned by tests.

Apple Health medications groundwork (server side of the medication-import
request): IntakeSource gains APPLE_HEALTH; medications accept an external
mirror identity (source-exclusive by policy) and dose imports carry the HK
dose-event UUID — both idempotent per stable key, with the concurrent-create
race resolved to the winner. Migration 0239, additive only. Entries without
the new fields behave byte-identically.

Hardening: doctor-report aggregation + PDF charts compute ranges with loops
instead of spread calls — a report over a sample-dense year cannot overflow
the call stack (the class fixed for the Google Health full sync).
This commit is contained in:
Marc-André Bombeck committed 2026-07-11 00:18:23 +02:00
1 parent 755da41019
commit c400e2aa88
29 files changed
+1585 -276

No files matched your search

+5
View File
@@ -485,6 +485,11 @@ async function buildAnalyticsResponse(user: AuthedUser) {
correlations,
healthScore,
sleepStages,
// v1.28.x — the latest night's source-discrepancy annotation, computed
// inside the slim slice's per-night post-pass. Additive: the dashboard
// sleep tile marks the headline with a discreet "sources disagree"
// hint; clients that don't read the field stay unchanged.
sleepSourceDiscrepancy: slim.sleepSourceDiscrepancy,
// v1.4.34 IW-B — per-type freshness map drives the dashboard's
// staleness caption on each `<TrendCard>`. Additive: clients that
// don't read the field stay unchanged.
@@ -572,6 +572,14 @@ describe("GET /api/dashboard/summary", () => {
latestValue: number | null;
unit: string | null;
sleepStages: Record<string, number> | null;
sleepSourceDiscrepancy: {
deltaMinutes: number;
sources: Array<{
source: string;
deviceType: string | null;
asleepMinutes: number;
}>;
} | null;
}>;
};
};
@@ -585,6 +593,82 @@ describe("GET /api/dashboard/summary", () => {
expect(sleep?.sleepStages?.CORE).toBeCloseTo(4, 2);
expect(sleep?.sleepStages?.DEEP).toBeCloseTo(1.5, 2);
expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2);
// v1.28.x — the additive discrepancy field is present and null-safe:
// source-less single-writer rows carry no annotation.
expect(sleep?.sleepSourceDiscrepancy).toBeNull();
// Every non-sleep kind pins the additive field to null.
for (const m of body.data.metrics) {
if (m.id !== "sleep") expect(m.sleepSourceDiscrepancy).toBeNull();
}
});
it("carries the latest night's source-discrepancy annotation on the sleep tile (v1.28.x)", async () => {
// Two writers disagree on the night: granular Apple (410 asleep) next
// to a bare Withings aggregate claiming 615. Apple wins the served
// total; the additive annotation surfaces the disagreement.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const wake = new Date("2026-06-04T06:00:00.000Z");
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "SLEEP_DURATION",
_count: { _all: 4 },
_max: { measuredAt: wake },
},
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
value: 240,
measuredAt: new Date("2026-06-04T00:00:00.000Z"),
sleepStage: "CORE",
source: "APPLE_HEALTH",
},
{
value: 90,
measuredAt: new Date("2026-06-04T02:00:00.000Z"),
sleepStage: "DEEP",
source: "APPLE_HEALTH",
},
{
value: 80,
measuredAt: new Date("2026-06-04T04:00:00.000Z"),
sleepStage: "REM",
source: "APPLE_HEALTH",
},
{
value: 615,
measuredAt: wake,
sleepStage: "ASLEEP",
source: "WITHINGS",
},
] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: {
metrics: Array<{
id: string;
latestValue: number | null;
sleepSourceDiscrepancy: {
deltaMinutes: number;
sources: Array<{
source: string;
deviceType: string | null;
asleepMinutes: number;
}>;
} | null;
}>;
};
};
const sleep = body.data.metrics.find((m) => m.id === "sleep");
expect(sleep, "sleep tile must be emitted").toBeDefined();
// Served total stays the winning writer's number (410 min → 6.83 h).
expect(sleep?.latestValue).toBeCloseTo(410 / 60, 2);
expect(sleep?.sleepSourceDiscrepancy).toEqual({
deltaMinutes: 205,
sources: [
{ source: "WITHINGS", deviceType: null, asleepMinutes: 615 },
{ source: "APPLE_HEALTH", deviceType: null, asleepMinutes: 410 },
],
});
});
it("emits the sleep-rhythm DTO (sleep-debt + chronotype) computed from the foundation modules (v1.17.0)", async () => {
+29
View File
@@ -60,6 +60,7 @@ import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import {
summarizeSleepNights,
reconstructSleepNights,
type SleepSourceDiscrepancy,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
@@ -130,6 +131,16 @@ interface MetricCard {
* headline `latestValue`.
*/
sleepStages: Partial<Record<SleepStage, number>> | null;
/**
* v1.28.x — source-discrepancy annotation for the headline night's MAIN
* session, sleep tile only; `null` for every other kind and for a night
* whose writers agree. Additive (the established wire pattern for the
* iOS-locked shape): observational only — `latestValue` stays the
* winning writer's total; clients render a discreet "sources disagree"
* hint next to the number. Minutes are whole (rounded), matching the
* `/api/sleep/night` serialisation.
*/
sleepSourceDiscrepancy: SleepSourceDiscrepancy | null;
trend: "up" | "down" | "flat" | "unknown";
sparkline: number[];
updatedAt: string | null;
@@ -707,6 +718,7 @@ async function buildDashboardSummary(
unitKey: METRIC_UNIT_KEYS.weight,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
@@ -747,6 +759,7 @@ async function buildDashboardSummary(
unitKey: METRIC_UNIT_KEYS.bloodPressure,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(sysSpark),
sparkline: sysSpark,
updatedAt:
@@ -776,6 +789,7 @@ async function buildDashboardSummary(
unitKey: METRIC_UNIT_KEYS.pulse,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
@@ -803,6 +817,7 @@ async function buildDashboardSummary(
unitKey: METRIC_UNIT_KEYS.bodyFat,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
@@ -862,6 +877,19 @@ async function buildDashboardSummary(
unit: "h",
sleepStages:
stageHours && Object.keys(stageHours).length > 0 ? stageHours : null,
// Additive, observational: the headline night's MAIN session saw
// two writer buckets with clearly different asleep totals. Round
// to whole minutes, matching the `/api/sleep/night` serialisation.
sleepSourceDiscrepancy: night?.sourceDiscrepancy
? {
deltaMinutes: Math.round(night.sourceDiscrepancy.deltaMinutes),
sources: night.sourceDiscrepancy.sources.map((b) => ({
source: b.source,
deviceType: b.deviceType,
asleepMinutes: Math.round(b.asleepMinutes),
})),
}
: null,
trend: trendOf(nightSpark),
sparkline: nightSpark,
updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt,
@@ -881,6 +909,7 @@ async function buildDashboardSummary(
unitKey: METRIC_UNIT_KEYS[kind],
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findMany: vi.fn().mockResolvedValue([]),
// v1.28 — the Apple-mirror idempotency pre-query.
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn(),
},
medicationIntakeEvent: {
@@ -497,3 +499,134 @@ describe("POST /api/medications — as-needed (v1.16.11, #316)", () => {
);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.28 — Apple Health mirrored medications (#423)
// ────────────────────────────────────────────────────────────────────
describe("POST /api/medications — Apple Health mirror (v1.28)", () => {
const MIRROR_BODY = {
name: "Ramipril",
dose: "5 mg",
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
};
function lastCreateData(): Record<string, unknown> {
const calls = vi.mocked(prisma.medication.create).mock.calls;
expect(calls.length).toBeGreaterThan(0);
return (calls[calls.length - 1][0] as { data: Record<string, unknown> })
.data;
}
it("persists externalSource + externalId field-by-field on create", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
const res = await POST(postReq(MIRROR_BODY));
expect(res.status).toBe(201);
const data = lastCreateData();
expect(data.externalSource).toBe("APPLE_HEALTH");
expect(data.externalId).toBe("hk-concept-1");
// The pre-query probed the mirror triple, user-scoped.
expect(prisma.medication.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId: "user-1",
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
},
}),
);
});
it("returns the EXISTING medication (200) on a re-post of the same triple — no duplicate row", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
id: "med-existing",
userId: "user-1",
name: "Ramipril",
dose: "5 mg",
unitsPerDose: 1,
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
schedules: [],
} as never);
const res = await POST(postReq(MIRROR_BODY));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { id: string; externalId: string; category: string };
};
expect(body.data.id).toBe("med-existing");
expect(body.data.externalId).toBe("hk-concept-1");
expect(body.data.category).toBe("OTHER");
// Idempotent replay: nothing written.
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("resolves a concurrent P2002 race on the mirror triple to the winning row (200)", async () => {
// Pre-query misses; the create then loses the race against a
// concurrent mirror of the same concept id.
vi.mocked(prisma.medication.findFirst)
.mockResolvedValueOnce(null as never)
.mockResolvedValueOnce({
id: "med-winner",
userId: "user-1",
name: "Ramipril",
dose: "5 mg",
unitsPerDose: 1,
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
schedules: [],
} as never);
vi.mocked(prisma.medication.create).mockRejectedValueOnce(
Object.assign(new Error("unique"), { code: "P2002" }),
);
const res = await POST(postReq(MIRROR_BODY));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { id: string } };
expect(body.data.id).toBe("med-winner");
});
it("422s when externalSource is supplied without externalId", async () => {
const res = await POST(postReq({ ...MIRROR_BODY, externalId: undefined }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; message: string }> };
};
expect(
body.details.issues.some((i) =>
i.message.includes("must be supplied together"),
),
).toBe(true);
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("422s when externalId is supplied without externalSource", async () => {
const res = await POST(
postReq({ ...MIRROR_BODY, externalSource: undefined }),
);
expect(res.status).toBe(422);
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("422s on a non-Apple externalSource value (server-owned sources stay closed)", async () => {
const res = await POST(postReq({ ...MIRROR_BODY, externalSource: "WEB" }));
expect(res.status).toBe(422);
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("a plain create keeps today's behavior — no external columns forwarded", async () => {
const res = await POST(
postReq({
name: "Ramipril",
dose: "5 mg",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
);
expect(res.status).toBe(201);
const data = lastCreateData();
expect("externalSource" in data).toBe(false);
expect("externalId" in data).toBe(false);
});
});
@@ -1088,3 +1088,273 @@ describe("POST /api/medications/intake/bulk — v1.16.10 inventory consumption",
expect(consumeForIntake).not.toHaveBeenCalled();
});
});
// ────────────────────────────────────────────────────────────────────
// v1.28 — Apple Health dose events (#423)
// ────────────────────────────────────────────────────────────────────
describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events", () => {
const MIRRORED_MED = {
id: "med-apple",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "s1",
windowStart: "07:00",
windowEnd: "07:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
},
],
};
beforeEach(() => {
// 20:30 Berlin on 2026-06-15 — past both fixture slots for the day.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-06-15T18:30:00.000Z"));
// The ownership read surfaces the mirror flag for the source gate.
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-apple", externalSource: "APPLE_HEALTH" },
] as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(
MIRRORED_MED as never,
);
});
afterEach(() => {
vi.useRealTimers();
});
it("422s the batch when an APPLE_HEALTH entry targets a NON-mirrored medication", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-native", externalSource: null },
] as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-native",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-1",
},
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe(
"medications.intake.bulk.apple_health_not_mirrored",
);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
});
it("422s an APPLE_HEALTH entry without externalId (both-or-neither refine)", async () => {
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
},
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("medications.intake.bulk.invalid");
});
it("422s an externalId without source APPLE_HEALTH", async () => {
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
externalId: "hk-dose-1",
},
],
}),
);
expect(res.status).toBe(422);
});
it("reports a replayed external dose-event UUID as duplicate without writing (idempotent re-sync)", async () => {
// The batch pre-check finds the UUID already live.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{ id: "row-existing", externalId: "hk-dose-1" },
] as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-1",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
duplicates: number;
inserted: number;
entries: Array<{ status: string; id?: string }>;
};
};
expect(body.data.duplicates).toBe(1);
expect(body.data.inserted).toBe(0);
expect(body.data.entries[0].status).toBe("duplicate");
expect(body.data.entries[0].id).toBe("row-existing");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
expect(consumeForIntake).not.toHaveBeenCalled();
});
it("inserts a fresh Apple dose with source APPLE_HEALTH + externalId stored; a same-batch repeat dedups", async () => {
// Dedup pre-check: nothing live for the batch's UUIDs.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce(
[] as never,
);
// 14:00 Berlin — off-band, so the take records standalone (ad-hoc).
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
id: "row-apple",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-2",
},
// The same dose event repeated inside ONE batch (a client-side
// pagination overlap) — must dedup in-memory, not double-insert.
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-2",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ status: string; id?: string }>;
};
};
expect(body.data.inserted).toBe(1);
expect(body.data.duplicates).toBe(1);
expect(body.data.entries[0].status).toBe("inserted");
expect(body.data.entries[1].status).toBe("duplicate");
expect(body.data.entries[1].id).toBe("row-apple");
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
source: "APPLE_HEALTH",
externalId: "hk-dose-2",
takenAt: new Date("2026-06-15T12:00:00.000Z"),
}),
}),
);
});
it("stamps the externalId onto the pending slot row when the Apple dose converges by band", async () => {
// Dedup pre-check first (empty), then the canonical-slot find returns
// the worker-minted pending REMINDER row for the 19:00 slot.
vi.mocked(prisma.medicationIntakeEvent.findMany)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([
{
id: "row-evening",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T17:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-evening",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T17:30:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-3",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { updated: number; entries: Array<{ status: string }> };
};
expect(body.data.updated).toBe(1);
expect(body.data.entries[0].status).toBe("updated");
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "row-evening" },
data: expect.objectContaining({ externalId: "hk-dose-3" }),
}),
);
});
it("keeps a source-less entry byte-identical to today: source API, no externalId column", async () => {
// No entry carries an externalId, so the dedup pre-check must not
// fire a findMany at all — the hot path pays zero extra reads.
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
id: "row-adhoc",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { inserted: number; entries: Array<{ status: string }> };
};
expect(body.data.inserted).toBe(1);
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
const createArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0][0] as { data: Record<string, unknown> };
expect(createArgs.data.source).toBe("API");
expect("externalId" in createArgs.data).toBe(false);
});
});
+167 -38
View File
@@ -20,6 +20,13 @@
* forceSlotInstant?, — ISO timestamp; pins a taken entry onto a
* named real scheduled slot (server-validated
* against the band anchors)
* source?, — "APPLE_HEALTH" only (v1.28 HealthKit dose
* import); absent keeps the default "API".
* Requires externalId and a medication
* mirrored from Apple Health.
* externalId?, — HK dose-event UUID (max 128); drives the
* idempotent re-sync dedup (first-write-wins
* per Apple dose)
* }
*
* Response (always 200): processed / inserted / duplicates / skipped /
@@ -77,39 +84,62 @@ const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkEntrySchema = z.object({
medicationId: z.string().min(1),
scheduledFor: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.9 — shared plausibility bounds (no future beyond clock skew,
// 5-year floor), matching the single-intake create + edit paths.
takenAt: boundedTakenAtSchema.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
// v1.8.5 — optional per-entry injection site. Validated against the
// medication's effective allowed set; a disallowed site marks the
// entry skipped (`injection_site_not_allowed`) without failing the
// whole batch, matching the bulk endpoint's per-entry contract.
injectionSite: injectionSiteEnum.optional(),
// v1.15.20 — late-take "attribute anyway" pin, mirroring the single
// intake route: pin a taken entry onto a named real scheduled slot
// instead of the default window-band attribution. Validated server-side
// against the band anchors; an instant that is not a slot marks the
// entry skipped (`force_slot_invalid`) per the bulk per-entry contract.
// Ignored on non-taken entries (a pending echo / skip carries no take
// to attribute).
forceSlotInstant: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.4 — per-entry dose override, mirroring the single intake
// route: free text (max 50 chars, like `Medication.dose`), persisted
// only on a taken entry. Absent = the configured medication dose
// applies.
doseTaken: z.string().trim().min(1).max(50).optional(),
});
const bulkEntrySchema = z
.object({
medicationId: z.string().min(1),
scheduledFor: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.9 — shared plausibility bounds (no future beyond clock skew,
// 5-year floor), matching the single-intake create + edit paths.
takenAt: boundedTakenAtSchema.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
// v1.8.5 — optional per-entry injection site. Validated against the
// medication's effective allowed set; a disallowed site marks the
// entry skipped (`injection_site_not_allowed`) without failing the
// whole batch, matching the bulk endpoint's per-entry contract.
injectionSite: injectionSiteEnum.optional(),
// v1.15.20 — late-take "attribute anyway" pin, mirroring the single
// intake route: pin a taken entry onto a named real scheduled slot
// instead of the default window-band attribution. Validated server-side
// against the band anchors; an instant that is not a slot marks the
// entry skipped (`force_slot_invalid`) per the bulk per-entry contract.
// Ignored on non-taken entries (a pending echo / skip carries no take
// to attribute).
forceSlotInstant: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.4 — per-entry dose override, mirroring the single intake
// route: free text (max 50 chars, like `Medication.dose`), persisted
// only on a taken entry. Absent = the configured medication dose
// applies.
doseTaken: z.string().trim().min(1).max(50).optional(),
// v1.28 — Apple Health dose-event import (iOS 26+ HealthKit
// Medications API). Only the APPLE_HEALTH literal is accepted — the
// other IntakeSource values stay server-owned; absent keeps today's
// default ("API"). Must carry `externalId` (refine below), and may
// only target a medication mirrored from Apple Health (route-level
// 422). logStatus mapping is client-side: iOS ships taken/skipped
// shaped like every other entry.
source: z.literal("APPLE_HEALTH").optional(),
// v1.28 — the HealthKit `HKMedicationDoseEvent` UUID. Drives the
// idempotent re-sync: an entry whose `(userId, externalId)` already
// exists live reports `duplicate` (first-write-wins per Apple dose).
externalId: z.string().trim().min(1).max(128).optional(),
})
.refine(
// v1.28 — both-or-neither: an Apple entry without its dose-event UUID
// cannot dedup, and a bare externalId without the source label has no
// defined provenance.
(e) => (e.source === "APPLE_HEALTH") === (e.externalId !== undefined),
{
message: "source APPLE_HEALTH and externalId must be supplied together",
path: ["externalId"],
},
);
const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
@@ -202,17 +232,70 @@ async function postBulk(request: NextRequest): Promise<Response> {
where: { id: { in: medicationIds }, userId: user.id },
// v1.8.5 — pull the injection-tracking fields so a per-entry
// `injectionSite` can be resolved + validated without an extra
// per-entry read.
// per-entry read. v1.28 — `externalSource` rides along so the
// Apple-entry mirror gate below needs no extra read either.
select: {
id: true,
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
externalSource: true,
},
});
const ownedSet = new Set(ownedMedications.map((m) => m.id));
const medById = new Map(ownedMedications.map((m) => [m.id, m]));
// v1.28 — source-exclusive Apple-mirrored medications: an APPLE_HEALTH
// dose event may only attach to a medication mirrored from Apple Health
// (`Medication.externalSource === "APPLE_HEALTH"`). Targeting a native
// medication is a client contract violation (the iOS sync maps HK dose
// events onto the meds it mirrored itself), not a per-entry data
// problem, so the whole batch 422s with a stable errorCode the client
// can branch on. Unowned medicationIds keep the per-entry
// `medication_not_found` skip below.
const appleMismatch = entries.some(
(e) =>
e.source === "APPLE_HEALTH" &&
ownedSet.has(e.medicationId) &&
medById.get(e.medicationId)?.externalSource !== "APPLE_HEALTH",
);
if (appleMismatch) {
return apiError(
"An APPLE_HEALTH entry may only target a medication mirrored from Apple Health",
422,
{ errorCode: "medications.intake.bulk.apple_health_not_mirrored" },
);
}
// v1.28 — idempotent re-sync dedup: one read collects every live row
// already carrying one of the batch's external dose-event UUIDs; the
// per-entry loop reports those entries as `duplicate` (first-write-wins
// per Apple dose) without touching the DB again. Fresh inserts register
// into the same map so an intra-batch repeat of a UUID dedups too. The
// partial `(user_id, external_id)` unique (migration 0239) is the race
// backstop.
const batchExternalIds = Array.from(
new Set(
entries
.map((e) => e.externalId)
.filter((id): id is string => id !== undefined),
),
);
const rowIdByExternalId = new Map<string, string>();
if (batchExternalIds.length > 0) {
const existingExternalRows = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
externalId: { in: batchExternalIds },
deletedAt: null,
},
select: { id: true, externalId: true },
});
for (const row of existingExternalRows) {
if (row.externalId) rowIdByExternalId.set(row.externalId, row.id);
}
}
// v1.8.5 — the user's global exclusion deny-list, loaded once for the
// whole batch (it is user-scoped, not per-medication). Only read when
// at least one entry actually carries an `injectionSite`, so the hot
@@ -262,6 +345,19 @@ async function postBulk(request: NextRequest): Promise<Response> {
continue;
}
// v1.28 — Apple dose-event replay: the batch pre-check found a live
// row already carrying this external UUID (or an earlier entry in
// THIS batch inserted it). First-write-wins per Apple dose — report
// `duplicate` with the existing row id so the sync cursor advances.
if (entry.externalId !== undefined) {
const existingRowId = rowIdByExternalId.get(entry.externalId);
if (existingRowId !== undefined) {
duplicates += 1;
results.push({ index: i, status: "duplicate", id: existingRowId });
continue;
}
}
try {
const incomingScheduledFor =
entry.scheduledFor ?? entry.takenAt ?? new Date();
@@ -414,11 +510,15 @@ async function postBulk(request: NextRequest): Promise<Response> {
isExplicitTaken,
isExplicitSkip,
idempotencyKey: entry.idempotencyKey ?? null,
createSource: "API",
// v1.28 — an Apple dose-event import stamps its provenance on a
// fresh row; every other caller keeps today's "API".
createSource: entry.source ?? "API",
injectionSite: resolvedInjectionSite,
attributionSource,
// v1.16.4 — dose override only documents a consumed dose.
doseTaken: (!entry.skipped && entry.doseTaken) || null,
// v1.28 — external dose-event UUID rides onto the landed row.
externalId: entry.externalId ?? null,
});
if (applied.noDowngradeNoOp) {
// C2 — pending echo onto an already-actioned slot. Report it as a
@@ -524,10 +624,13 @@ async function postBulk(request: NextRequest): Promise<Response> {
isExplicitTaken,
isExplicitSkip,
idempotencyKey: entry.idempotencyKey ?? null,
createSource: "API",
// v1.28 — Apple provenance on a fresh row (see above).
createSource: entry.source ?? "API",
injectionSite: resolvedInjectionSite,
attributionSource,
doseTaken: (!entry.skipped && entry.doseTaken) || null,
// v1.28 — external dose-event UUID rides onto the landed row.
externalId: entry.externalId ?? null,
});
if (applied.noDowngradeNoOp) {
duplicates += 1;
@@ -573,8 +676,15 @@ async function postBulk(request: NextRequest): Promise<Response> {
scheduledFor: effectiveScheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
source: "API",
// v1.28 — Apple provenance on a standalone (ad-hoc) row;
// every other caller keeps today's "API".
source: entry.source ?? "API",
idempotencyKey: entry.idempotencyKey ?? null,
// v1.28 — external dose-event UUID for the idempotent
// re-sync dedup.
...(entry.externalId !== undefined && {
externalId: entry.externalId,
}),
// v1.8.5 — site only on a resolved taken-injection entry.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
@@ -612,6 +722,13 @@ async function postBulk(request: NextRequest): Promise<Response> {
// only when the row's state actually changed (inserted / updated). A
// pending echo, duplicate, or no-downgrade no-op leaves the visible
// dose state untouched, so it must not wake the user's other devices.
// v1.28 — register the landed row under its external dose-event
// UUID so a repeat of the same UUID later in THIS batch dedups
// in-memory instead of tripping the partial unique.
const landed = results[results.length - 1];
if (entry.externalId !== undefined && landed?.id !== undefined) {
rowIdByExternalId.set(entry.externalId, landed.id);
}
const lastStatus = results[results.length - 1]?.status;
if (lastStatus === "inserted" || lastStatus === "updated") {
const scheduledForIso = effectiveScheduledFor.toISOString();
@@ -638,7 +755,19 @@ async function postBulk(request: NextRequest): Promise<Response> {
where: { idempotencyKey: entry.idempotencyKey },
select: { id: true },
})
: null;
: // v1.28 — an Apple entry that raced the partial
// `(user_id, external_id)` unique (concurrent batches syncing
// the same dose event) resolves to the winning live row.
entry.externalId
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
externalId: entry.externalId,
deletedAt: null,
},
select: { id: true },
})
: null;
duplicates += 1;
results.push({
index: i,
+185 -97
View File
@@ -280,6 +280,43 @@ export const GET = apiHandler(async () => {
return apiSuccess(result);
});
/**
* v1.28 — idempotent response for a re-mirrored medication. The create
* route returns the EXISTING row (status 200, vs 201 for a fresh create)
* when the caller re-posts a `(externalSource, externalId)` pair this
* user already mirrors — the iOS re-sync replays its full HealthKit
* medication list, and a replay must neither duplicate nor 500. Response
* shape mirrors the fresh-create envelope (schedules included, category
* joined, Decimal unwrapped); no audit row and no cache eviction because
* nothing was written.
*/
async function respondWithExistingMirror(
medication: Record<string, unknown> & { id: string; unitsPerDose: unknown },
): Promise<Response> {
let category = "OTHER";
try {
const categories = await getMedicationCategories([medication.id]);
category = categories[medication.id] ?? "OTHER";
} catch {
getEvent()?.addWarning("Medication categories could not be loaded");
}
annotate({
action: {
name: "medication.create",
entity_type: "medication",
entity_id: medication.id,
},
meta: { idempotent_replay: true },
});
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
category,
});
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -310,6 +347,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
criticalAlarmEnabled,
atcCode,
rxNormCode,
externalSource,
externalId,
schedules,
startsOn,
endsOn,
@@ -317,6 +356,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
asNeeded,
} = parsed.data;
// v1.28 — idempotent mirror create. A mirrored medication (Apple
// Health) is keyed by the `(userId, externalSource, externalId)`
// unique; a re-post of the same concept id (iOS re-sync, retry after a
// dropped response) returns the EXISTING medication with 200 instead
// of minting a duplicate or 500-ing on P2002. The pre-query covers the
// common replay; the P2002 catch around the create below covers the
// concurrent race.
if (externalSource !== undefined && externalId !== undefined) {
const existing = await prisma.medication.findFirst({
where: { userId: user.id, externalSource, externalId },
include: { schedules: true },
});
if (existing) {
return respondWithExistingMirror(existing);
}
}
// ── v1.5 route invariants for the new scheduling primitives ───────
//
// The Zod layer already enforces `rrule` xor `rollingIntervalDays`
@@ -367,106 +423,138 @@ export const POST = apiHandler(async (request: NextRequest) => {
const normalisedEndsOn =
oneShot === true && startsOn ? startsOn : (endsOn ?? undefined);
const medication = await prisma.medication.create({
data: {
userId: user.id,
name,
dose,
// v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the
// wire schema; Prisma fills the default GENERIC when omitted.
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted.
...(unitsPerDose !== undefined && { unitsPerDose }),
// v1.17.0 — optional reorder lead override; null/omitted = inherit
// the user-level default in the low-stock alert.
...(reorderLeadDays !== undefined && { reorderLeadDays }),
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites; Prisma defaults to false / [] when omitted.
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
// Validated for format by Zod; stored verbatim when present.
...(atcCode !== undefined && { atcCode }),
...(rxNormCode !== undefined && { rxNormCode }),
// v1.5 — wizard's reminders toggle now ships through the create
// payload (was orphaned in the initial diff). Prisma defaults to
// true when omitted, matching the legacy form's behaviour.
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.5 scheduling primitives — pass-through when supplied.
...(startsOn !== undefined && { startsOn }),
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
...(oneShot !== undefined && { oneShot }),
// v1.16.11 — as-needed flag, field-by-field. An asNeeded create
// carries an empty `scheduleInputs`, so the nested create below
// persists zero schedule rows.
...(asNeeded !== undefined && { asNeeded }),
schedules: {
create: scheduleInputs.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
// v1.7.0 — PRN schedules carry no cadence, so never default
// them to FREQ=DAILY (they would otherwise project + remind).
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const isPrn = s.scheduleType === "PRN";
const defaultedRrule =
!oneShot &&
!isPrn &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
? "FREQ=DAILY"
: s.rrule;
const createMedication = () =>
prisma.medication.create({
data: {
userId: user.id,
name,
dose,
// v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the
// wire schema; Prisma fills the default GENERIC when omitted.
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted.
...(unitsPerDose !== undefined && { unitsPerDose }),
// v1.17.0 — optional reorder lead override; null/omitted = inherit
// the user-level default in the low-stock alert.
...(reorderLeadDays !== undefined && { reorderLeadDays }),
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites; Prisma defaults to false / [] when omitted.
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
// Validated for format by Zod; stored verbatim when present.
...(atcCode !== undefined && { atcCode }),
...(rxNormCode !== undefined && { rxNormCode }),
// v1.28 — mirrored-medication provenance (Apple Health). Zod
// enforces the both-or-neither pairing; field-by-field like every
// other column.
...(externalSource !== undefined && { externalSource }),
...(externalId !== undefined && { externalId }),
// v1.5 — wizard's reminders toggle now ships through the create
// payload (was orphaned in the initial diff). Prisma defaults to
// true when omitted, matching the legacy form's behaviour.
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.5 scheduling primitives — pass-through when supplied.
...(startsOn !== undefined && { startsOn }),
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
...(oneShot !== undefined && { oneShot }),
// v1.16.11 — as-needed flag, field-by-field. An asNeeded create
// carries an empty `scheduleInputs`, so the nested create below
// persists zero schedule rows.
...(asNeeded !== undefined && { asNeeded }),
schedules: {
create: scheduleInputs.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
// v1.7.0 — PRN schedules carry no cadence, so never default
// them to FREQ=DAILY (they would otherwise project + remind).
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const isPrn = s.scheduleType === "PRN";
const defaultedRrule =
!oneShot &&
!isPrn &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
? "FREQ=DAILY"
: s.rrule;
// Invariant 3 — dual-write `timesOfDay` from windowStart when
// the legacy-shape iOS client doesn't send the new field.
const effectiveTimesOfDay =
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart];
// Invariant 3 — dual-write `timesOfDay` from windowStart when
// the legacy-shape iOS client doesn't send the new field.
const effectiveTimesOfDay =
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart];
return {
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
// v1.5 first-class times-of-day.
timesOfDay: effectiveTimesOfDay,
...(s.reminderGraceMinutes !== undefined && {
reminderGraceMinutes: s.reminderGraceMinutes,
}),
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && {
scheduleType: s.scheduleType,
}),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
// v1.15.18 — per-dose configurable on-time windows. Stored as the
// validated `{ timeOfDay, start, end }[]` JSON; absent leaves the
// column NULL (every slot on the default ±1h derivation).
...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }),
};
}),
return {
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
// v1.5 first-class times-of-day.
timesOfDay: effectiveTimesOfDay,
...(s.reminderGraceMinutes !== undefined && {
reminderGraceMinutes: s.reminderGraceMinutes,
}),
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && {
scheduleType: s.scheduleType,
}),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
// v1.15.18 — per-dose configurable on-time windows. Stored as the
// validated `{ timeOfDay, start, end }[]` JSON; absent leaves the
// column NULL (every slot on the default ±1h derivation).
...(s.doseWindows !== undefined && {
doseWindows: s.doseWindows,
}),
};
}),
},
},
},
include: { schedules: true },
});
include: { schedules: true },
});
let medication: Awaited<ReturnType<typeof createMedication>>;
try {
medication = await createMedication();
} catch (err: unknown) {
// v1.28 — P2002 on the mirror triple: a concurrent create of the
// same `(userId, externalSource, externalId)` won the race between
// the pre-query above and this insert. Resolve to the winning row
// and answer idempotently instead of 500-ing. Any other unique
// violation (there is none on this table today) rethrows.
const isP2002 =
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2002";
if (isP2002 && externalSource !== undefined && externalId !== undefined) {
const winner = await prisma.medication.findFirst({
where: { userId: user.id, externalSource, externalId },
include: { schedules: true },
});
if (winner) return respondWithExistingMirror(winner);
}
throw err;
}
const normalizedCategory = await setMedicationCategory(
medication.id,