mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
1 parent
755da41019
commit
c400e2aa88
29 files changed
+1585
-276
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user