mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #628 from MBombeck/release/v1.32.25
release/v1.32.25 — cap external medication-mirror growth and surface provenance
This commit is contained in:
11 files changed
+519
-11
No files matched your search
@@ -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
|
||||
|
||||
+23
-7
@@ -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:
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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<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();
|
||||
});
|
||||
@@ -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<unknown>,
|
||||
) => 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<Response>
|
||||
)(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();
|
||||
});
|
||||
});
|
||||
@@ -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 ───────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -204,7 +204,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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 } },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user