mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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).
This commit is contained in:
1 parent
816f517d1b
commit
6c8e921faa
2 files changed
+251
No files matched your search
@@ -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<void> {
|
||||
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<string>();
|
||||
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();
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<string, number>();
|
||||
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();
|
||||
});
|
||||
Reference in new issue
Block a user