From 6c8e921faa6f2f71953f1a4aa2c47a6a6a0c1be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 23:02:03 +0200 Subject: [PATCH 1/5] chore(scripts): add read-only diagnose + guarded cleanup for phantom Apple-Health mirror meds The diagnose script reports the provenance, external-id values, and intake/schedule counts of medications by name. The cleanup script deletes only name-matched Apple-Health mirror rows with zero intakes and zero schedules, dry-run by default, replicating the delete route (category cleanup, token revoke, audit, cache bust). --- scripts/cleanup-apple-health-phantom-meds.ts | 130 +++++++++++++++++++ scripts/diagnose-apple-health-meds.ts | 121 +++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 scripts/cleanup-apple-health-phantom-meds.ts create mode 100644 scripts/diagnose-apple-health-meds.ts diff --git a/scripts/cleanup-apple-health-phantom-meds.ts b/scripts/cleanup-apple-health-phantom-meds.ts new file mode 100644 index 000000000..3c5e94646 --- /dev/null +++ b/scripts/cleanup-apple-health-phantom-meds.ts @@ -0,0 +1,130 @@ +/** + * 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(); + }); From dbe3c3dc26cbcd2c2f9d31dcaa2ee62191533156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 23:18:14 +0200 Subject: [PATCH 2/5] fix(medications): cap externally mirrored rows and surface their provenance A client that mints a fresh externalId for the same source concept on every sync sweep never matches the (userId, externalSource, externalId) idempotency triple, so each sweep would otherwise create a new mirror row without bound. Add a growth guard on POST /api/medications: once an account already holds 30 medications mirrored from an external source, a genuinely new mirror create is refused with 422 and the errorCode medications.mirror.limit_exceeded. The guard sits after the idempotency lookup, so an idempotent re-post of a medication the account already mirrors still returns 200, and native creates never run the count. Echo externalSource on the medications list read so the web UI and an operator can tell a mirrored row from a native one, and document both in the OpenAPI contract. --- src/app/api/medications/route.ts | 34 +++++++++++++++++++ src/lib/medications/list-read.ts | 5 +++ src/lib/openapi/routes/medications/paths.ts | 2 +- src/lib/openapi/routes/medications/schemas.ts | 10 +++++- 4 files changed, 49 insertions(+), 2 deletions(-) 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 From 1007ca3d1c0da21423ddf84406bcc0fe6456f69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 23:18:26 +0200 Subject: [PATCH 3/5] test(medications): cover the mirror growth guard and provenance echo A 31st new mirror create at the cap returns 422 with the limit_exceeded errorCode; an idempotent re-post of an existing mirror at the cap still returns 200; a native create at the cap is unaffected and never runs the count. The list read exposes externalSource as APPLE_HEALTH for a mirror and null for a native row. --- .../api/medications/__tests__/route.test.ts | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) 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(); + }); +}); From 40aae4b66dd16c2c72f2f1a0e78e4d6a028e0799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 23:18:26 +0200 Subject: [PATCH 4/5] chore(scripts): apply prettier to the phantom-med cleanup script Formatting only; the format:check gate flagged the file. No behaviour change. --- scripts/cleanup-apple-health-phantom-meds.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/cleanup-apple-health-phantom-meds.ts b/scripts/cleanup-apple-health-phantom-meds.ts index 3c5e94646..cd9f76a45 100644 --- a/scripts/cleanup-apple-health-phantom-meds.ts +++ b/scripts/cleanup-apple-health-phantom-meds.ts @@ -66,7 +66,9 @@ async function main(): Promise { deletable.push({ id: m.id, userId: m.userId }); } - console.log(`\n=== cleanup plan for name "${name}" (externalSource APPLE_HEALTH) ===`); + 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}`); @@ -103,7 +105,11 @@ async function main(): Promise { await prisma.medication.delete({ where: { id } }); await auditLog("medication.delete", { userId, - details: { medicationId: id, name, reason: "apple_health_phantom_cleanup" }, + details: { + medicationId: id, + name, + reason: "apple_health_phantom_cleanup", + }, }); affectedUsers.add(userId); deleted += 1; @@ -113,7 +119,9 @@ async function main(): Promise { invalidateUserMedications(userId, { evict: true }); } - console.log(`\nDone. Deleted ${deleted} row(s) across ${affectedUsers.size} user(s).`); + 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.", ); From bb79d289110caf52c2d371dc3e0872ef0f0b40ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 23:18:26 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20v1.32.25=20=E2=80=94=20su?= =?UTF-8?q?rface=20mirrored-medication=20provenance=20and=20cap=20external?= =?UTF-8?q?=20mirror=20growth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 17 +++++++++++++++++ docs/api/openapi.yaml | 30 +++++++++++++++++++++++------- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 42 insertions(+), 9 deletions(-) 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/*`