diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index de6424398..fd576ea10 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -285,7 +285,10 @@ export const PUT = apiHandler( // below excludes every actioned row — and the previous cadence is the one // genuinely unrecoverable part, which is what C7 puts in the audit row. // The DELETE beside it stays refused. - const { user } = await requireRecordAuth("manage", "medications"); + const { user, authMethod } = await requireRecordAuth( + "manage", + "medications", + ); const { id } = await params; // v1.5.5 C-E3-3 — route ownership check through the shared helper @@ -1061,7 +1064,24 @@ export const PUT = apiHandler( if (projected) medication = { ...medication, ...projected }; } - if (category !== undefined) await setMedicationCategory(id, category); + // v1.40 (#1041) — an iPhone app build that predates custom categories + // decodes an unknown category as OTHER and sends it back on every edit, + // which would overwrite the person's own category with OTHER. A native + // client (Bearer) sending OTHER over a custom category keeps the custom + // one; moving a custom category back to OTHER stays possible on the web. + let categoryToWrite = category; + if (category === "OTHER" && authMethod === "bearer") { + const current = (await resolveMedicationCategories([id]))[id]; + if (current?.category.startsWith("custom:")) { + categoryToWrite = undefined; + annotate({ + action: { name: "medication.update.custom_category_kept" }, + meta: { entity_id: id }, + }); + } + } + if (categoryToWrite !== undefined) + await setMedicationCategory(id, categoryToWrite); const resolvedCategory = (await resolveMedicationCategories([id]))[id]; const normalizedCategory = resolvedCategory?.category ?? "OTHER"; diff --git a/tests/integration/medication-custom-categories.test.ts b/tests/integration/medication-custom-categories.test.ts index 694359423..262ea7e75 100644 --- a/tests/integration/medication-custom-categories.test.ts +++ b/tests/integration/medication-custom-categories.test.ts @@ -192,6 +192,49 @@ describe("custom medication categories", () => { expect(body.data).toMatchObject({ category: key, categoryLabel: "Heart" }); }); + it("keeps a custom category when an older iPhone build sends OTHER back", async () => { + const key = await createCategory("Heart"); + const created = await (await createMedication(key)).json(); + const { hashToken } = await import("@/lib/auth/hmac"); + const raw = "hlk_custom-category-native-0000"; + await getPrismaClient().apiToken.create({ + data: { + userId: OWNER, + name: "native", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + cookieJar.clear(); + headerJar.set("authorization", `Bearer ${raw}`); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${created.data.id}`, { + name: "Renamed", + category: "OTHER", + }), + { params: Promise.resolve({ id: created.data.id }) }, + ); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(200); + expect(body.data).toMatchObject({ category: key, categoryLabel: "Heart" }); + headerJar.clear(); + await signIn(OWNER); + }); + + it("moves a custom category back to OTHER when the web asks", async () => { + const key = await createCategory("Heart"); + const created = await (await createMedication(key)).json(); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${created.data.id}`, { category: "OTHER" }), + { params: Promise.resolve({ id: created.data.id }) }, + ); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(200); + expect(body.data.category).toBe("OTHER"); + }); + it("renames, hides and caps", async () => { const key = await createCategory("Heart"); const { PATCH } =