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] 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(); + });