diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b42cf3fd..e5c8f92b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +## [1.32.25] — 2026-07-24 + +Mirrored medications are now marked as such, and the server limits how many +medications one external source can mirror so a client bug can no longer fill +the list with duplicates. + +- **Mirrored medications are marked on the list.** The medications list now + reports whether each entry was mirrored from an external source (today Apple + Health) or created in HealthLog directly. A synced row is no longer + indistinguishable from one you added yourself. +- **One external source can no longer flood the list.** The server refuses to + mirror more than 30 medications from a single external source per account. A + client that keeps re-sending the same medication under a changing identifier + can no longer create a fresh row on every sync. A repeat of a medication + already mirrored still resolves to the existing row, and medications you add + yourself are never affected. + ## [1.32.24] — 2026-07-24 Polar sync is more resilient when one data type is unavailable. A single diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0f7ef5f94..37001e20a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.24 + version: 1.32.25 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -3526,7 +3526,12 @@ paths: summary: Create a medication with at least one schedule description: Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its - schedules in a single Prisma write. Audits as `medication.create`. + schedules in a single Prisma write. Audits as `medication.create`. v1.28 — a mirror create (`externalSource` + + `externalId`) re-posting a pair the caller already holds returns the existing medication with 200 instead of a + duplicate. v1.32.25 — a NEW mirror create is refused with 422 (`meta.errorCode = + medications.mirror.limit_exceeded`) once the caller already holds 30 medications mirrored from the same external + source; an idempotent re-post of a medication already mirrored is unaffected, and native creates are never + gated. requestBody: required: true content: @@ -19794,6 +19799,15 @@ components: $ref: "#/components/schemas/MedicationScheduleOutput" category: $ref: "#/components/schemas/MedicationCategory" + externalSource: + anyOf: + - type: string + const: APPLE_HEALTH + - type: "null" + description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ + HealthKit Medications sync; NULL is a native HealthLog medication. Read-only echo of the stored provenance — + the create route sets it, the list read surfaces it so mirrored-vs-native is visible without inspecting the + iOS client's local registry. lastTakenAt: anyOf: - type: string @@ -19851,16 +19865,18 @@ components: - updatedAt - schedules - category + - externalSource - lastTakenAt - todayEventCount - stockUnitsRemaining - stockDosesRemaining additionalProperties: false - description: List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, - `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the - dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, - `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, - `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics. + description: List-row variant of the medication resource enriched with the joined `category`, the v1.32.25 + `externalSource` provenance echo, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields + (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields + (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, + `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the + `Medication` component for their semantics. MedicationCategory: type: string enum: diff --git a/package.json b/package.json index 1ef486bb1..42b589920 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.24", + "version": "1.32.25", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 0ad558d70..c27c3ffc0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.24"; + /* @sw-version-fallback */ "v1.32.25"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/cleanup-apple-health-phantom-meds.ts b/scripts/cleanup-apple-health-phantom-meds.ts new file mode 100644 index 000000000..cd9f76a45 --- /dev/null +++ b/scripts/cleanup-apple-health-phantom-meds.ts @@ -0,0 +1,138 @@ +/** + * Remove the phantom Apple-Health mirror medications minted by the unstable + * externalId bug (a fresh key per sync sweep => one duplicate row per sweep). + * + * SAFETY: + * - Dry-run by default. Deletes ONLY when called with `--execute`. + * - Targets ONLY rows that match ALL of: the given name, externalSource = + * APPLE_HEALTH, ZERO intake events, and ZERO schedules. A row with any + * intake history or any schedule is never touched. Real medications + * (different name, or with intake/schedule history) can never match. + * - Each delete replicates the DELETE /api/medications/{id} route exactly: + * revoke medication-scoped tokens, deleteMedicationCategory, cascade + * delete, audit row. Caches are busted per affected user at the end (and + * otherwise clear on TTL / the next write). + * + * STOP THE SOURCE FIRST: while the iOS Apple-Health medication mirror is still + * enabled on the device, every sync re-mints these rows and cleanup will undo + * itself. Turn the toggle off (or ship the iOS fix) before running --execute. + * + * Run (inside the app container): + * pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts # dry-run + * pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts --execute # delete + * pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts --name "Blutdruckmittel" --execute + */ +import "dotenv/config"; + +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { deleteMedicationCategory } from "@/lib/medication-category"; + +function argValue(flag: string): string | undefined { + const i = process.argv.indexOf(flag); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main(): Promise { + const name = argValue("--name") ?? "Blutdruckmittel"; + const execute = process.argv.includes("--execute"); + + // Candidate set: name + Apple-Health provenance only. Intake / schedule + // counts are checked per row below and are the hard safety gate. + const candidates = await prisma.medication.findMany({ + where: { name, externalSource: "APPLE_HEALTH" }, + select: { id: true, userId: true, externalId: true, createdAt: true }, + orderBy: { createdAt: "asc" }, + }); + + const deletable: { id: string; userId: string }[] = []; + const skipped: { id: string; reason: string }[] = []; + + for (const m of candidates) { + const [intakes, schedules] = await Promise.all([ + prisma.medicationIntakeEvent.count({ + where: { medicationId: m.id, deletedAt: null }, + }), + prisma.medicationSchedule.count({ where: { medicationId: m.id } }), + ]); + if (intakes > 0 || schedules > 0) { + skipped.push({ + id: m.id, + reason: `has ${intakes} intake(s) / ${schedules} schedule(s) — PRESERVED`, + }); + continue; + } + deletable.push({ id: m.id, userId: m.userId }); + } + + console.log( + `\n=== cleanup plan for name "${name}" (externalSource APPLE_HEALTH) ===`, + ); + console.log(`candidates found: ${candidates.length}`); + console.log(`safe to delete: ${deletable.length}`); + console.log(`preserved (intake/schedule present): ${skipped.length}`); + if (skipped.length > 0) { + console.log("\nPreserved rows:"); + for (const s of skipped) console.log(` ${s.id} — ${s.reason}`); + } + console.log("\nRows to delete:"); + for (const d of deletable) console.log(` ${d.id}`); + + if (!execute) { + console.log( + "\nDRY RUN — nothing was deleted. Re-run with --execute to remove the rows above.", + ); + return; + } + + console.log(`\n--execute: deleting ${deletable.length} row(s)...`); + const affectedUsers = new Set(); + let deleted = 0; + for (const { id, userId } of deletable) { + // Mirror the DELETE route: revoke medication-scoped tokens (none expected + // on a phantom row, harmless if absent), category cleanup, cascade delete, + // audit row. + await prisma.apiToken.updateMany({ + where: { + userId, + revoked: false, + permissions: { has: `medication:${id}:ingest` }, + }, + data: { revoked: true }, + }); + await deleteMedicationCategory(id); + await prisma.medication.delete({ where: { id } }); + await auditLog("medication.delete", { + userId, + details: { + medicationId: id, + name, + reason: "apple_health_phantom_cleanup", + }, + }); + affectedUsers.add(userId); + deleted += 1; + } + + for (const userId of affectedUsers) { + invalidateUserMedications(userId, { evict: true }); + } + + console.log( + `\nDone. Deleted ${deleted} row(s) across ${affectedUsers.size} user(s).`, + ); + console.log( + "Caches were busted for the affected users; a page refresh reflects the change.", + ); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => { + void prisma.$disconnect(); + }); diff --git a/scripts/diagnose-apple-health-meds.ts b/scripts/diagnose-apple-health-meds.ts new file mode 100644 index 000000000..ee1aa7269 --- /dev/null +++ b/scripts/diagnose-apple-health-meds.ts @@ -0,0 +1,121 @@ +/** + * Read-only diagnostic for the phantom Apple-Health medication mirror. + * + * Prints the shape of every medication matching a name (default + * "Blutdruckmittel"): its provenance (externalSource / externalId), dose, + * asNeeded flag, createdAt, and its intake-event + schedule counts. Nothing is + * written. Use it to confirm the root cause before running any cleanup: + * + * - externalSource = APPLE_HEALTH with many DISTINCT externalId values + * (address-shaped or otherwise mutually different) => the iOS mirror is + * minting a fresh key per sync, so idempotency never matches. + * - externalSource NULL => the rows are not mirror rows (a different path). + * - createdAt spread => one row per sync sweep vs a single burst. + * + * Run (inside the app container, which has DATABASE_URL): + * pnpm dlx tsx scripts/diagnose-apple-health-meds.ts + * pnpm dlx tsx scripts/diagnose-apple-health-meds.ts --name "Blutdruckmittel" + */ +import "dotenv/config"; + +import { prisma } from "@/lib/db"; + +function argValue(flag: string): string | undefined { + const i = process.argv.indexOf(flag); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main(): Promise { + const name = argValue("--name") ?? "Blutdruckmittel"; + + const meds = await prisma.medication.findMany({ + where: { name }, + select: { + id: true, + userId: true, + name: true, + dose: true, + asNeeded: true, + externalSource: true, + externalId: true, + createdAt: true, + }, + orderBy: { createdAt: "asc" }, + }); + + if (meds.length === 0) { + console.log(`No medications found with name "${name}".`); + return; + } + + const rows = []; + for (const m of meds) { + const [intakes, schedules] = await Promise.all([ + prisma.medicationIntakeEvent.count({ + where: { medicationId: m.id, deletedAt: null }, + }), + prisma.medicationSchedule.count({ where: { medicationId: m.id } }), + ]); + rows.push({ + id: m.id, + user: m.userId.slice(0, 8), + source: m.externalSource ?? "NULL", + externalId: m.externalId ?? "NULL", + dose: m.dose, + asNeeded: m.asNeeded, + intakes, + schedules, + createdAt: m.createdAt.toISOString(), + }); + } + + console.log(`\n=== ${rows.length} medication(s) named "${name}" ===\n`); + console.table(rows); + + const distinctExternalIds = new Set( + meds.map((m) => m.externalId).filter((v): v is string => v !== null), + ); + const bySource = new Map(); + for (const m of meds) { + const key = m.externalSource ?? "NULL"; + bySource.set(key, (bySource.get(key) ?? 0) + 1); + } + const created = meds.map((m) => m.createdAt.getTime()); + const withIntake = rows.filter((r) => r.intakes > 0).length; + const withSchedule = rows.filter((r) => r.schedules > 0).length; + + console.log("\n=== summary ==="); + console.log(`total rows: ${rows.length}`); + console.log( + `externalSource breakdown: ${[...bySource.entries()] + .map(([k, v]) => `${k}=${v}`) + .join(", ")}`, + ); + console.log(`distinct externalId: ${distinctExternalIds.size}`); + console.log(`rows with >=1 intake: ${withIntake}`); + console.log(`rows with >=1 schedule: ${withSchedule}`); + console.log( + `createdAt range: ${new Date(Math.min(...created)).toISOString()} .. ${new Date(Math.max(...created)).toISOString()}`, + ); + console.log( + "\nSample externalId values (first 5):\n" + + meds + .slice(0, 5) + .map((m) => ` ${m.externalId ?? "NULL"}`) + .join("\n"), + ); + console.log( + "\nRead-only. No rows were modified. If the shape confirms the phantom set,\n" + + "run scripts/cleanup-apple-health-phantom-meds.ts (dry-run first).", + ); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => { + void prisma.$disconnect(); + }); diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 17a683f29..c61b568dd 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -10,6 +10,8 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn().mockResolvedValue([]), // v1.28 — the Apple-mirror idempotency pre-query. findFirst: vi.fn().mockResolvedValue(null), + // v1.32.25 — the mirror growth-guard count. + count: vi.fn().mockResolvedValue(0), create: vi.fn(), }, medicationIntakeEvent: { @@ -76,6 +78,7 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { cachedSwr } from "@/lib/cache/server-cache"; import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { getMedicationCategories } from "@/lib/medication-category"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -93,6 +96,9 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // v1.32.25 — default the mirror growth-guard count below the cap so the + // pre-existing mirror-create tests hit the create path, not the guard. + vi.mocked(prisma.medication.count).mockResolvedValue(0 as never); // Default happy-path stub: return the data shape the caller passed in // so test assertions can inspect what the route actually forwarded // to Prisma without re-implementing the create-returning DB. @@ -630,3 +636,166 @@ describe("POST /api/medications — Apple Health mirror (v1.28)", () => { expect("externalId" in data).toBe(false); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.32.25 — mirror growth guard + provenance echo +// ──────────────────────────────────────────────────────────────────── + +describe("POST /api/medications — mirror growth guard (v1.32.25)", () => { + const MIRROR_BODY = { + name: "Blutdruckmittel", + dose: "1", + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-new", + asNeeded: true, + }; + + it("refuses a NEW mirror create (422) once the user already holds the cap", async () => { + // Idempotency miss (genuinely new row) + user already at 30 mirrors. + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.medication.count).mockResolvedValue(30 as never); + + const res = await POST(postReq(MIRROR_BODY)); + expect(res.status).toBe(422); + const body = (await res.json()) as { + error: string; + meta: { errorCode: string }; + }; + expect(body.meta.errorCode).toBe("medications.mirror.limit_exceeded"); + // Nothing minted — the guard blocks the write. + expect(prisma.medication.create).not.toHaveBeenCalled(); + // The count was scoped to the user's Apple-Health mirrors. + expect(prisma.medication.count).toHaveBeenCalledWith({ + where: { userId: "user-1", externalSource: "APPLE_HEALTH" }, + }); + }); + + it("still returns 200 for an idempotent re-post of an EXISTING mirror while at the cap — the guard never blocks a replay", async () => { + // Even at/over the cap, a re-post of a mirror the user already holds + // must resolve to the existing row (200), because the idempotency + // lookup HITS before the growth guard runs. + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "med-existing", + userId: "user-1", + name: "Blutdruckmittel", + dose: "1", + unitsPerDose: 1, + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-new", + schedules: [], + } as never); + vi.mocked(prisma.medication.count).mockResolvedValue(50 as never); + + 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-existing"); + // Replay: neither the guard count nor a write ran. + expect(prisma.medication.count).not.toHaveBeenCalled(); + expect(prisma.medication.create).not.toHaveBeenCalled(); + }); + + it("does not gate a native create (no externalSource) even while at the cap", async () => { + // A native medication has no external provenance, so the guard branch + // is never entered — the count query must not even run. + vi.mocked(prisma.medication.count).mockResolvedValue(999 as never); + + const res = await POST( + postReq({ + name: "Ramipril", + dose: "5 mg", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); + expect(res.status).toBe(201); + expect(prisma.medication.count).not.toHaveBeenCalled(); + expect(prisma.medication.create).toHaveBeenCalled(); + }); + + it("admits a NEW mirror create below the cap (201)", async () => { + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.medication.count).mockResolvedValue(29 as never); + + const res = await POST(postReq(MIRROR_BODY)); + expect(res.status).toBe(201); + expect(prisma.medication.create).toHaveBeenCalled(); + }); +}); + +describe("GET /api/medications — provenance echo (v1.32.25)", () => { + function rewireListRead(medications: unknown[]) { + vi.mocked(cachedSwr).mockImplementation((async ( + _c: unknown, + _k: string, + f: () => Promise, + ) => f()) as never); + vi.mocked(getUserTodayBounds).mockReturnValue({ + start: new Date("2026-07-24T00:00:00Z"), + end: new Date("2026-07-24T23:59:59Z"), + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue( + medications as never, + ); + vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationInventoryItem.groupBy).mockResolvedValue( + [] as never, + ); + // `vi.resetAllMocks()` clears the factory default; the list read reads + // `categoryMap[m.id]` outside its try/catch, so a non-empty row set + // needs the category map to resolve to an object. + vi.mocked(getMedicationCategories).mockResolvedValue({}); + } + + it("exposes externalSource on each row: APPLE_HEALTH for a mirror, null for a native med", async () => { + rewireListRead([ + { + id: "med-mirror", + userId: "user-1", + name: "Blutdruckmittel", + dose: "1", + unitsPerDose: 1, + externalSource: "APPLE_HEALTH", + externalId: "hk-1", + createdAt: new Date("2026-07-20T00:00:00Z"), + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [], + }, + { + id: "med-native", + userId: "user-1", + name: "Ramipril", + dose: "5 mg", + unitsPerDose: 1, + externalSource: null, + externalId: null, + createdAt: new Date("2026-07-19T00:00:00Z"), + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [], + }, + ]); + + const res = await ( + GET as unknown as (req: NextRequest) => Promise + )(new NextRequest("http://localhost/api/medications")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ id: string; externalSource: string | null }>; + }; + const mirror = body.data.find((m) => m.id === "med-mirror"); + const native = body.data.find((m) => m.id === "med-native"); + expect(mirror?.externalSource).toBe("APPLE_HEALTH"); + expect(native?.externalSource).toBeNull(); + }); +}); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 97cc5c2e8..cb81bd279 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -19,6 +19,15 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { readMedicationsListCached } from "@/lib/medications/list-read"; import { NextRequest } from "next/server"; +// v1.32.25 — blast-radius cap on externally-mirrored medications per user. +// A client that mints a fresh `externalId` for the same source concept on +// every sync sweep (a known iOS unstable-key bug class) never matches the +// idempotency triple, so each sweep would otherwise create a new mirror +// row unbounded. Apple's own medication list makes >30 genuinely distinct +// meds implausible, so this caps the damage a buggy client can do without +// breaking any legitimate use. +const APPLE_HEALTH_MIRROR_CAP = 30; + export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -133,6 +142,31 @@ export const POST = apiHandler(async (request: NextRequest) => { if (existing) { return respondWithExistingMirror(existing); } + + // v1.32.25 — growth guard. This point is only reached when the + // idempotency lookup MISSED, i.e. this is a genuinely NEW mirror row, + // not a replay of one the user already holds (a replay returned 200 + // above). An idempotent re-post of an existing mirror is therefore + // never blocked, even for a user already at the cap. Refuse only the + // minting of a fresh row once the user already holds the cap of + // Apple-Health-mirrored medications, so a client feeding a rotating + // key can no longer fill the list with duplicates. The count runs on + // the mirror-create-miss path only; native creates (no + // `externalSource`) never reach this branch and pay nothing. + const mirroredCount = await prisma.medication.count({ + where: { userId: user.id, externalSource: "APPLE_HEALTH" }, + }); + if (mirroredCount >= APPLE_HEALTH_MIRROR_CAP) { + annotate({ + action: { name: "medication.mirror.limit_exceeded" }, + meta: { existing: mirroredCount }, + }); + return apiError( + "This account already mirrors the maximum number of medications from an external source", + 422, + { errorCode: "medications.mirror.limit_exceeded" }, + ); + } } // ── v1.5 route invariants for the new scheduling primitives ─────── diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index e5c3426ef..8f9889f75 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -239,6 +239,11 @@ export async function buildMedicationsList( // the string Prisma would otherwise serialise a Decimal to. unitsPerDose: Number(m.unitsPerDose), category: categoryMap[m.id] ?? "OTHER", + // v1.32.25 — provenance echo. Surfacing the mirror source lets the + // web UI and an operator tell an externally-mirrored row (today only + // Apple Health) from a native HealthLog medication. NULL for a native + // medication. + externalSource: m.externalSource ?? null, lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null, todayEventCount: todayEventCountByMedId[m.id] ?? 0, nextDueAt: display ? display.at.toISOString() : null, diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 5f6e1784a..e7d6fef22 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -204,7 +204,7 @@ export const medicationPaths: NonNullable = { tags: ["Medications"], summary: "Create a medication with at least one schedule", description: - "Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`.", + "Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`. v1.28 — a mirror create (`externalSource` + `externalId`) re-posting a pair the caller already holds returns the existing medication with 200 instead of a duplicate. v1.32.25 — a NEW mirror create is refused with 422 (`meta.errorCode = medications.mirror.limit_exceeded`) once the caller already holds 30 medications mirrored from the same external source; an idempotent re-post of a medication already mirrored is unaffected, and native creates are never gated.", requestBody: { required: true, content: { "application/json": { schema: createMedicationSchema } }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 71b291f4f..ec2d35972 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -223,6 +223,14 @@ export const medicationResource = z export const medicationListEntry = medicationResource .extend({ category: medicationCategoryEnum, + // v1.32.25 — mirrored-medication provenance echoed on the list read so + // the web UI and an operator can tell a mirror row from a native one. + externalSource: z + .literal("APPLE_HEALTH") + .nullable() + .describe( + "v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ HealthKit Medications sync; NULL is a native HealthLog medication. Read-only echo of the stored provenance — the create route sets it, the list read surfaces it so mirrored-vs-native is visible without inspecting the iOS client's local registry.", + ), // The list read always computes these two, so they are required here // even though the shared base leaves them optional for the write paths. nextDueAt: z.iso.datetime({ offset: true }).nullable(), @@ -257,7 +265,7 @@ export const medicationListEntry = medicationResource .meta({ id: "MedicationListEntry", description: - "List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.", + "List-row variant of the medication resource enriched with the joined `category`, the v1.32.25 `externalSource` provenance echo, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.", }); export const medicationDetailEntry = medicationResource