From 4cb81188022be5a033a42da9c8d9d9b61ca870b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 21:38:03 +0200 Subject: [PATCH] Let a person name their own medication categories A medication can be filed under a category the person named, next to the built-in ones (#1041). The labels live in medication_category_labels, encrypted at rest, capped at 20 per account; a medication filed under one carries its custom: key in the category column. - GET/POST /api/medications/categories (POST idempotent, 30/min per actor, admitted at MANAGE like the cycle symptom vocabulary) and owner-only PATCH/DELETE /api/medications/categories/{key}. DELETE moves every medication filed under the category to OTHER in the same transaction and answers movedCount. - category is widened to a built-in value or custom: on the wire, the create and update bodies refuse a key that is not the caller's (422 medications.category.unknown), and list, detail, the insights medication tiles and the MCP medications resource carry categoryLabel. A key with no owned label reads as OTHER and is counted on the event. - The wizard's class step lists the person's categories under the built-in rows with an inline add row; the list page gains a category filter (session only) and a manage sheet for rename, hide and delete. - Backup carries customMedicationCategories (decrypted labels) and the restore writes them before the medications; wipe plan, encrypted column registry, rotation script, sharing guards and OpenAPI follow. --- docs/api/openapi.yaml | 414 +++++++++++++++--- .../migration.sql | 22 + prisma/schema.prisma | 29 ++ scripts/rotate-encryption-key.ts | 9 + .../destructive-control-guard.test.ts | 8 + src/__tests__/sharing-surface-guard.test.ts | 17 +- .../success-affordance-guard.test.ts | 4 + src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- .../comprehensive/__tests__/route.test.ts | 12 +- src/app/api/insights/comprehensive/route.ts | 9 +- .../medications/[id]/__tests__/route.test.ts | 17 +- src/app/api/medications/[id]/route.ts | 28 +- .../api/medications/__tests__/route.test.ts | 17 +- .../api/medications/categories/[key]/route.ts | 151 +++++++ src/app/api/medications/categories/route.ts | 126 ++++++ src/app/api/medications/route.ts | 30 +- src/app/insights/medications/page.tsx | 17 +- .../__tests__/category-filter.test.tsx | 140 ++++++ src/app/medications/page-client.tsx | 151 ++++++- .../detail/medication-detail-tabs.tsx | 2 + .../medications/glp1-medication-card.tsx | 8 +- .../medications/medication-card.tsx | 8 +- .../medication-categories-sheet.tsx | 285 ++++++++++++ .../medication-category-add-row.tsx | 122 ++++++ .../medications/use-medication-categories.ts | 97 ++++ .../wizard/__tests__/wizard-payload.test.ts | 82 ++++ .../medications/wizard/steps/step2-class.tsx | 146 ++++-- .../medications/wizard/wizard-payload.ts | 49 ++- src/lib/__tests__/medication-category.test.ts | 179 ++++++++ src/lib/crypto/encrypted-columns.ts | 3 +- src/lib/data-wipe/wipe-plan.ts | 3 + .../__tests__/full-backup-payload.test.ts | 1 + .../restore-backup-round-trip.test.ts | 3 + src/lib/export/backup-plan.ts | 4 + src/lib/export/full-backup-payload.ts | 12 +- src/lib/export/restore-backup.ts | 40 +- src/lib/mcp/__tests__/resources.test.ts | 19 + src/lib/mcp/resources.ts | 15 + src/lib/medication-category.ts | 268 +++++++++++- .../__tests__/category-filter.test.ts | 65 +++ src/lib/medications/category-filter.ts | 66 +++ src/lib/medications/category-label.ts | 13 + src/lib/medications/list-read.ts | 16 +- src/lib/openapi/error-codes.ts | 3 + src/lib/openapi/routes/medications/paths.ts | 144 +++++- src/lib/openapi/routes/medications/schemas.ts | 48 +- src/lib/query-keys/medications.ts | 7 + src/lib/record-activity/activity-verb.ts | 4 + src/lib/validations/backup.ts | 23 +- src/lib/validations/medication/base.ts | 61 ++- .../validations/medication/create-update.ts | 6 +- tests/fixtures/v137/sharing-matrix.ts | 8 + tests/integration/backup-round-trip.test.ts | 58 ++- .../medication-custom-categories.test.ts | 299 +++++++++++++ .../sharing-manage-handler-matrix.test.ts | 18 +- 55 files changed, 3201 insertions(+), 187 deletions(-) create mode 100644 src/app/api/medications/categories/[key]/route.ts create mode 100644 src/app/api/medications/categories/route.ts create mode 100644 src/app/medications/__tests__/category-filter.test.tsx create mode 100644 src/components/medications/medication-categories-sheet.tsx create mode 100644 src/components/medications/medication-category-add-row.tsx create mode 100644 src/components/medications/use-medication-categories.ts create mode 100644 src/lib/__tests__/medication-category.test.ts create mode 100644 src/lib/medications/__tests__/category-filter.test.ts create mode 100644 src/lib/medications/category-filter.ts create mode 100644 tests/integration/medication-custom-categories.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cf0c25eb9..ab0c02b1a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5800,7 +5800,8 @@ paths: 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. + gated. v1.40 — `category` may be the key of one of the caller's own categories; any other `custom:` key is 422 + `medications.category.unknown`. parameters: - *a6 requestBody: @@ -5821,6 +5822,124 @@ paths: "409": *a7 "422": *a4 "429": *a5 + /api/medications/categories: + get: + tags: + - Medications + summary: List the record's own medication categories + description: v1.40 (#1041) — every custom category of the record, hidden ones included, in picker order (`sortOrder`, + then creation), each with the number of medications filed under it. The built-in categories are not listed; they + are the fixed values of `MedicationCategory`. Delegable at READ level over the `medications` section. + responses: + "200": + description: The custom categories. + content: + application/json: + schema: + $ref: "#/components/schemas/ListMedicationCategoriesResponse" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + post: + tags: + - Medications + summary: Create a custom medication category + description: "v1.40 (#1041) — mints a `custom:` key and stores the label encrypted. At most 20 per account, hidden + ones included: one more is 422 `medications.category.limitReached`; an invalid body is 422 + `medications.category.invalid`. Rate-limited 30/min keyed on the actor. Delegable at MANAGE level over the + `medications` section. The label never reaches an audit row." + parameters: + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationCategoryRequest" + responses: + "201": + description: The new category. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationCategoryResponse" + "401": *a1 + "403": *a11 + "409": *a7 + "422": *a4 + "429": *a5 + /api/medications/categories/{key}: + patch: + tags: + - Medications + summary: Rename, reorder or hide a custom medication category + description: v1.40 (#1041) — field-by-field update of one of the record's own categories. Another account's key, or a + built-in value, is 404. Rate-limited 30/min keyed on the actor. Delegable at MANAGE level over the `medications` + section. + parameters: + - in: path + name: key + schema: + type: string + description: "`custom:`, the category's key." + required: true + description: "`custom:`, the category's key." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationCategoryRequest" + responses: + "200": + description: The updated category. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationCategoryResponse" + "401": *a1 + "403": *a11 + "404": + description: Not one of the record's own categories. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + delete: + tags: + - Medications + summary: Delete a custom medication category + description: v1.40 (#1041) — deletes the category and, in the same transaction, moves every medication filed under it to + OTHER; `movedCount` says how many moved. No medication is left pointing at a key that no longer exists. Another + account's key, or a built-in value, is 404. Delegable at MANAGE level over the `medications` section. + parameters: + - in: path + name: key + schema: + type: string + description: "`custom:`, the category's key." + required: true + description: "`custom:`, the category's key." + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteMedicationCategoryResponse" + "401": *a1 + "403": *a11 + "404": + description: Not one of the record's own categories. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/medications/layout: get: tags: @@ -5932,7 +6051,9 @@ paths: description: Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST - /api/medications`. Audits as `medication.update`. + /api/medications`. Audits as `medication.update`. v1.40 — an absent `category` leaves the stored one untouched + (send it only when the person changed it); a `custom:` key that is not the caller's is 422 + `medications.category.unknown`. parameters: - in: path name: id @@ -24281,22 +24402,28 @@ components: minLength: 1 maxLength: 50 category: - type: string - enum: - - BLOOD_PRESSURE - - VITAMIN - - SUPPLEMENT - - PAIN_RELIEF - - ALLERGY - - DIGESTIVE - - THYROID - - HORMONE - - SKIN - - SLEEP_AID - - DIABETES - - ANTIBIOTIC - - MENTAL_HEALTH - - OTHER + anyOf: + - type: string + enum: + - BLOOD_PRESSURE + - VITAMIN + - SUPPLEMENT + - PAIN_RELIEF + - ALLERGY + - DIGESTIVE + - THYROID + - HORMONE + - SKIN + - SLEEP_AID + - DIABETES + - ANTIBIOTIC + - MENTAL_HEALTH + - OTHER + - type: string + pattern: ^custom:[0-9a-f-]{36}$ + description: A built-in clinical category (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, DIGESTIVE, + THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or `custom:`, the key + of one of the caller's own categories. treatmentClass: type: string enum: @@ -24568,6 +24695,31 @@ components: description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc. + CreateMedicationCategoryRequest: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + required: + - label + description: v1.40 — name a new medication category. The label is trimmed, 1 to 40 characters, and encrypted at rest. + UpdateMedicationCategoryRequest: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + sortOrder: + type: integer + minimum: 0 + maximum: 1000 + isActive: + type: boolean + description: "v1.40 — rename, reorder or hide a custom medication category. At least one field. Hiding (`isActive: + false`) removes it from the picker; medications filed under it keep it." MedicationListLayoutPutBody: type: object properties: @@ -24618,22 +24770,28 @@ components: minLength: 1 maxLength: 50 category: - type: string - enum: - - BLOOD_PRESSURE - - VITAMIN - - SUPPLEMENT - - PAIN_RELIEF - - ALLERGY - - DIGESTIVE - - THYROID - - HORMONE - - SKIN - - SLEEP_AID - - DIABETES - - ANTIBIOTIC - - MENTAL_HEALTH - - OTHER + anyOf: + - type: string + enum: + - BLOOD_PRESSURE + - VITAMIN + - SUPPLEMENT + - PAIN_RELIEF + - ALLERGY + - DIGESTIVE + - THYROID + - HORMONE + - SKIN + - SLEEP_AID + - DIABETES + - ANTIBIOTIC + - MENTAL_HEALTH + - OTHER + - type: string + pattern: ^custom:[0-9a-f-]{36}$ + description: A built-in clinical category (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, DIGESTIVE, + THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or `custom:`, the key + of one of the caller's own categories. treatmentClass: type: string enum: @@ -31924,7 +32082,8 @@ components: measurement.batch.too_large, measurement.bulk-delete.invalid, measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; medication — - medication.intake.import.invalid_format; medications — + medication.intake.import.invalid_format; medications — medications.category.invalid, + medications.category.limitReached, medications.category.unknown, medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, medications.intake.bulk.too_large, medications.intake.force_slot.invalid, medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, @@ -35247,6 +35406,36 @@ components: - efficacyTargets - scheduleRevisions additionalProperties: {} + customMedicationCategories: + default: [] + type: array + items: + type: object + properties: + key: + type: string + pattern: ^custom:[0-9a-f-]{36}$ + label: + type: string + minLength: 1 + maxLength: 40 + sortOrder: + default: 0 + type: integer + minimum: 0 + maximum: 9007199254740991 + isActive: + default: true + type: boolean + createdAt: + type: string + minLength: 1 + required: + - key + - label + - sortOrder + - isActive + additionalProperties: {} intakeEvents: default: [] type: array @@ -38816,6 +39005,7 @@ components: - accountSettings - measurements - medications + - customMedicationCategories - intakeEvents - moodEntries - cycleProfile @@ -44576,6 +44766,12 @@ components: $ref: "#/components/schemas/MedicationScheduleOutput" category: $ref: "#/components/schemas/MedicationCategory" + categoryLabel: + anyOf: + - type: string + - type: "null" + description: v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in + category, which a client localises from `category`. Read-only. externalSource: anyOf: - type: string @@ -44668,6 +44864,7 @@ components: - updatedAt - schedules - category + - categoryLabel - externalSource - courseStatus - intakeActionable @@ -44685,22 +44882,11 @@ components: `Medication` component for their semantics. MedicationCategory: type: string - enum: - - BLOOD_PRESSURE - - VITAMIN - - SUPPLEMENT - - PAIN_RELIEF - - ALLERGY - - DIGESTIVE - - THYROID - - HORMONE - - SKIN - - SLEEP_AID - - DIABETES - - ANTIBIOTIC - - MENTAL_HEALTH - - OTHER - description: Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`. + description: Clinical category. One of the built-in values (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, + DIGESTIVE, THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or, since v1.40, + `custom:`, the key of one of the caller's own categories (see `GET /api/medications/categories`), whose + label rides beside it as `categoryLabel`. A client must decode an unknown value tolerantly and must not write + `category` back unless the person changed it. Orthogonal to `MedicationTreatmentClass`. CreateMedicationResponse: type: object properties: @@ -44862,6 +45048,12 @@ components: $ref: "#/components/schemas/MedicationScheduleOutput" category: $ref: "#/components/schemas/MedicationCategory" + categoryLabel: + anyOf: + - type: string + - type: "null" + description: v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in + category, which a client localises from `category`. Read-only. courseStatus: type: string enum: @@ -44922,6 +45114,7 @@ components: - updatedAt - schedules - category + - categoryLabel - courseStatus - intakeActionable - stockUnitsRemaining @@ -44930,6 +45123,125 @@ components: additionalProperties: false description: Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics. + ListMedicationCategoriesResponse: + type: object + properties: + data: + type: object + properties: + categories: + type: array + items: + $ref: "#/components/schemas/MedicationCategoryLabel" + required: + - categories + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationCategoryLabel: + type: object + properties: + key: + type: string + description: "`custom:`, the value a medication's `category` carries when it is filed under this category." + label: + type: string + description: The person's own label, 1 to 40 characters. + sortOrder: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Position in the picker and the filter row, ascending. + isActive: + type: boolean + description: false = hidden from the picker. Medications filed under a hidden category keep it. + medicationCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: How many of the record's medications are filed under it. + required: + - key + - label + - sortOrder + - isActive + - medicationCount + additionalProperties: false + description: v1.40 (#1041) — a medication category the person named themselves, shown next to the built-in ones. At most + 20 per account (hidden ones count). + CreateMedicationCategoryResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationCategoryLabel" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateMedicationCategoryResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationCategoryLabel" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteMedicationCategoryResponse: + type: object + properties: + data: + type: object + properties: + key: + type: string + movedCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Medications moved to OTHER. + required: + - key + - movedCount + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationListLayoutResponse: type: object properties: diff --git a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql index d649406d1..23d971b47 100644 --- a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql +++ b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql @@ -18,3 +18,25 @@ CREATE TABLE IF NOT EXISTS "medication_categories" ( CREATE INDEX IF NOT EXISTS "medication_categories_category_idx" ON "medication_categories"("category"); + +-- Custom medication categories (#1041): the person's own labels, shown next +-- to the built-in ones. A medication filed under one carries its `key` in +-- `medication_categories.category`. Idempotent. +CREATE TABLE IF NOT EXISTS "medication_category_labels" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label_encrypted" BYTEA NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "medication_category_labels_pkey" PRIMARY KEY ("id"), + CONSTRAINT "medication_category_labels_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS "medication_category_labels_key_key" + ON "medication_category_labels"("key"); +CREATE INDEX IF NOT EXISTS "medication_category_labels_user_id_sort_order_idx" + ON "medication_category_labels"("user_id", "sort_order"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c7cd9569c..68d977d82 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -720,6 +720,7 @@ model User { medications Medication[] intakeEvents MedicationIntakeEvent[] medicationPauseEras MedicationPauseEra[] + medicationCategoryLabels MedicationCategoryLabel[] apiTokens ApiToken[] // v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted // authorization for an MCP client, audience-bound to health:read/health:write. @@ -2832,6 +2833,34 @@ model MedicationCategoryAssignment { @@map("medication_categories") } +/// v1.40 (#1041) — a medication category the person named themselves, shown +/// next to the built-in ones. A medication filed under it carries the row's +/// `key` (`custom:`) in `MedicationCategoryAssignment.category`; the +/// list and detail DTOs publish the decrypted label as `categoryLabel`. +/// Deleting a label moves its medications to OTHER in the same transaction, +/// so the app never writes a dangling key; one a restore leaves behind reads +/// as OTHER. Capped at 20 per account. +model MedicationCategoryLabel { + id String @id @default(cuid()) + userId String @map("user_id") + /// `custom:`, the handle the category column, the backup and the + /// iPhone app use. Never reused. + key String @unique + /// The person's own label, AES-256-GCM at rest. Never logged or audited. + labelEncrypted Bytes @map("label_encrypted") + /// Position in the picker and the filter row, ascending. + sortOrder Int @default(0) @map("sort_order") + /// false = hidden from the picker; medications filed under it keep it. + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, sortOrder]) + @@map("medication_category_labels") +} + /// v1.28 — the user's explicit override of a medication's efficacy target, /// the ONE thing the "Wirkung" view persists. Class→outcome resolution /// (ATC-prefix → name inference) is computed each read from the closed diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index a814f2365..4dfee834f 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -373,6 +373,15 @@ async function main() { prisma.cycleSymptom, ), ); + // v1.40 (#1041) — custom medication categories. A Bytes column, unlike the + // three String label columns above. + results.push( + await rotateBytesColumn( + "MedicationCategoryLabel", + "labelEncrypted", + prisma.medicationCategoryLabel, + ), + ); // ───── NotificationChannel."config" (encrypted JSON) ───── // Channel config (Telegram chat id, ntfy topic, etc.). Skipping these on diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 49b4bf75f..03fe54a8d 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -467,6 +467,14 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "permanent", confirm: ["AlertDialog"], }, + { + file: "components/medications/use-medication-categories.ts", + destroys: + "one of the person's own medication categories; the medications filed under it move to Other, they are not deleted", + triggers: ["components/medications/medication-categories-sheet.tsx"], + recovery: "permanent", + confirm: ["AlertDialog"], + }, { file: "components/medications/use-medication-intake.ts", destroys: "an intake event recorded seconds ago", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 9a5e17cd0..95c04a524 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1023,6 +1023,10 @@ const DELEGABLE_ROUTES: Record = { domain: "medications", why: "The record's medication cabinet. The cached list projection keys on the same resolved id it scopes to, so the owner's cell holds the owner's data. Returns no ingest endpoint and no token count — the route that does is refused.", }, + "app/api/medications/categories/route.ts": { + domain: "medications", + why: "The record's own medication category vocabulary, read and added to from the wizard. The labels and the per-category counts are scoped to the resolved id; renaming and deleting live in the owner-only `[key]` sibling.", + }, "app/api/medications/[id]/route.ts": { domain: "medications", why: "One medication of the record, fetch-then-guard against the resolved user before anything is serialised.", @@ -1646,6 +1650,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C1"], why: "Adding to the record's own symptom vocabulary. C1.", }, + "app/api/medications/categories/route.ts": { + domain: "medications", + conditions: ["C1"], + why: "Adding to the record's own medication category vocabulary, the cycle symptom precedent. C1.", + }, "app/api/medications/[id]/route.ts": { domain: "medications", conditions: ["C4", "C7"], @@ -1960,8 +1969,12 @@ const ACTOR_ROUTES: Record = { * v1.39.2 -- the body-site view adds one: a read on the record list in the * visits' domain, with conditions joined only under an illness grant. * 231 -> 232. + * + * v1.40 -- the custom medication categories add two: the vocabulary read on + * the record list and its create on the manage literal, the cycle symptom + * precedent. Rename and delete stay owner-only. 232 -> 234. */ -const FROZEN_ENTRY_COUNT = 232; +const FROZEN_ENTRY_COUNT = 234; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2722,7 +2735,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(78); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 383bf15d3..8ddf90e93 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -267,6 +267,10 @@ const PINNED_AFFORDANCES: Record< "src/components/medications/sections/inventory-section.tsx": { "toast.success": 1, }, + // v1.40 — the delete names how many medications moved to Other. + "src/components/medications/medication-categories-sheet.tsx": { + "toast.success": 1, + }, "src/components/medications/sections/notifications-section.tsx": { "toast.success": 1, }, diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 34bfae2db..6206614c8 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(78); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 3ffce0401..2e9758306 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -97,7 +97,7 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ })); vi.mock("@/lib/medication-category", () => ({ - getMedicationCategories: vi.fn(async () => ({})), + resolveMedicationCategories: vi.fn(async () => ({})), })); // The route no longer gates on the `insights` module (the AI analysis @@ -117,7 +117,7 @@ import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; import { prisma } from "@/lib/db"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { resolveMedicationCategories } from "@/lib/medication-category"; import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache"; const SESSION_OK = { @@ -922,8 +922,8 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing ( ( prisma.medicationIntakeEvent.findMany as ReturnType ).mockResolvedValue(intakeEvents); - vi.mocked(getMedicationCategories).mockResolvedValue({ - "med-bp": "BLOOD_PRESSURE", + vi.mocked(resolveMedicationCategories).mockResolvedValue({ + "med-bp": { category: "BLOOD_PRESSURE", categoryLabel: null }, }); } @@ -1127,8 +1127,8 @@ describe("GET /api/insights/comprehensive — an unlogged day is not a zero", () skipped: e.skipped, })), ); - vi.mocked(getMedicationCategories).mockResolvedValue({ - "med-bp": "BLOOD_PRESSURE", + vi.mocked(resolveMedicationCategories).mockResolvedValue({ + "med-bp": { category: "BLOOD_PRESSURE", categoryLabel: null }, }); } diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 6309dfb22..da41187b3 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -22,7 +22,7 @@ import { lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { resolveMedicationCategories } from "@/lib/medication-category"; import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; import { @@ -401,9 +401,12 @@ export async function buildComprehensiveResponse(user: AuthedUser) { // medication and would otherwise report perfect adherence. const medications = medicationRows.filter(expectsDoses); - const categoryMap = await getMedicationCategories( + const resolvedCategories = await resolveMedicationCategories( medications.map((m) => m.id), ); + const categoryMap = Object.fromEntries( + Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]), + ); const medCompliance = []; const bpMedicationEvents: Array<{ @@ -461,6 +464,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) { name: med.name, dose: med.dose, category: categoryMap[med.id] ?? "OTHER", + // v1.40 (#1041) — the label of a custom category; null for a built-in. + categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null, compliance7: c7.rate, compliance30: c30.rate, streak: c7.streak, diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 7fb97d866..3bdc698f4 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -52,7 +52,9 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ vi.mock("@/lib/medication-category", () => ({ deleteMedicationCategory: vi.fn().mockResolvedValue(undefined), getMedicationCategories: vi.fn().mockResolvedValue({}), - setMedicationCategory: vi.fn().mockResolvedValue(undefined), + resolveMedicationCategories: vi.fn().mockResolvedValue({}), + isAssignableCategory: vi.fn().mockResolvedValue(true), + setMedicationCategory: vi.fn().mockResolvedValue("OTHER"), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -70,7 +72,12 @@ vi.mock("next/headers", () => ({ import { PUT } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { + getMedicationCategories, + isAssignableCategory, + resolveMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; import { auditLog } from "@/lib/auth/audit"; import { dayKeyForScheduledFor, @@ -95,6 +102,12 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // v1.40 — the category helpers' defaults, which `resetAllMocks` clears. + vi.mocked(resolveMedicationCategories).mockResolvedValue({}); + vi.mocked(isAssignableCategory).mockResolvedValue(true); + vi.mocked(setMedicationCategory).mockImplementation( + async (_id, category) => (category ?? "OTHER") as never, + ); vi.mocked(prisma.medication.findUnique).mockResolvedValue({ id: "m1", userId: "user-1", diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index dbae9fc1a..ad3e6c63b 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -14,7 +14,8 @@ import { updateMedicationSchema } from "@/lib/validations/medication"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { deleteMedicationCategory, - getMedicationCategories, + isAssignableCategory, + resolveMedicationCategories, setMedicationCategory, } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; @@ -103,9 +104,11 @@ export const GET = apiHandler( } let category = "OTHER"; + let categoryLabel: string | null = null; try { - const categories = await getMedicationCategories([id]); - category = categories[id] ?? "OTHER"; + const categories = await resolveMedicationCategories([id]); + category = categories[id]?.category ?? "OTHER"; + categoryLabel = categories[id]?.categoryLabel ?? null; } catch { // Category enrichment is optional } @@ -242,6 +245,7 @@ export const GET = apiHandler( // intake tracking is off (see `scheduleWireFields`). ...scheduleWireFields(medication.trackIntake, schedulesDto), category, + categoryLabel, nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, // v1.39.4 (#1040) — see `resolveIntakeActionability`. @@ -383,6 +387,16 @@ export const PUT = apiHandler( reminderGraceMinutes: topLevelGraceMinutes, } = input; + // v1.40 (#1041) — a custom category must be one of this record's own. + if ( + category !== undefined && + !(await isAssignableCategory(user.id, category)) + ) { + return apiError("Unknown medication category", 422, { + errorCode: "medications.category.unknown", + }); + } + // v1.39.1 (#1033) — intake tracking transition. Off keeps the schedule // as a record; the live era from here on expects nothing. const wasTracked = existing.trackIntake !== false; @@ -988,10 +1002,9 @@ export const PUT = apiHandler( } } - const normalizedCategory = - category !== undefined - ? await setMedicationCategory(id, category) - : ((await getMedicationCategories([id]))[id] ?? "OTHER"); + if (category !== undefined) await setMedicationCategory(id, category); + const resolvedCategory = (await resolveMedicationCategories([id]))[id]; + const normalizedCategory = resolvedCategory?.category ?? "OTHER"; await auditLog("medication.update", { userId: user.id, @@ -1054,6 +1067,7 @@ export const PUT = apiHandler( ), ), category: normalizedCategory, + categoryLabel: resolvedCategory?.categoryLabel ?? null, }); }, ); diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 888ef60c7..6f58b809e 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -53,7 +53,9 @@ vi.mock("@/lib/cache/server-cache", () => ({ })); vi.mock("@/lib/medication-category", () => ({ getMedicationCategories: vi.fn().mockResolvedValue({}), - setMedicationCategory: vi.fn().mockResolvedValue(undefined), + resolveMedicationCategories: vi.fn().mockResolvedValue({}), + isAssignableCategory: vi.fn().mockResolvedValue(true), + setMedicationCategory: vi.fn().mockResolvedValue("OTHER"), })); vi.mock("@/lib/tz/local-day", async (importOriginal) => ({ // The schedule engine reads the real local-day helpers; only the today @@ -81,7 +83,12 @@ 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"; +import { + getMedicationCategories, + isAssignableCategory, + resolveMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -99,6 +106,12 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // v1.40 — the category helpers' defaults, which `resetAllMocks` clears. + vi.mocked(resolveMedicationCategories).mockResolvedValue({}); + vi.mocked(isAssignableCategory).mockResolvedValue(true); + vi.mocked(setMedicationCategory).mockImplementation( + async (_id, category) => (category ?? "OTHER") 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); diff --git a/src/app/api/medications/categories/[key]/route.ts b/src/app/api/medications/categories/[key]/route.ts new file mode 100644 index 000000000..85974a8bc --- /dev/null +++ b/src/app/api/medications/categories/[key]/route.ts @@ -0,0 +1,151 @@ +/** + * v1.40 (#1041) — rename, reorder, hide or delete one of the person's own + * medication categories. + * + * Owner-only, like the cycle symptom vocabulary's edit and delete: a + * delegate may add a category from the wizard (the POST beside this file is + * admitted at MANAGE), but renaming or removing the owner's vocabulary stays + * with the owner. Both handlers resolve the key against the caller's own rows + * only; another account's key, or a built-in value, is a 404. DELETE moves every medication + * filed under the category to OTHER in the same transaction and answers how + * many moved, so the app never leaves a medication pointing at a key that no + * longer exists. The label is never logged. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + decryptCategoryLabel, + deleteMedicationCategoryLabel, + encryptCategoryLabel, +} from "@/lib/medication-category"; +import { + isCustomMedicationCategoryKey, + updateMedicationCategoryLabelSchema, +} from "@/lib/validations/medication"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ key: string }> }; + +async function rateLimited(userId: string): Promise { + const rl = await checkRateLimit( + `medications:category:custom:${userId}`, + 30, + 60_000, + ); + return !rl.allowed; +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + if (await rateLimited(user.id)) { + return apiError("Too many requests, try again later", 429); + } + + const { key } = await params; + if (!isCustomMedicationCategoryKey(key)) { + return apiError("Category not found", 404); + } + + const { data: raw, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = updateMedicationCategoryLabelSchema.safeParse(raw); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "medications.category.invalid", + }); + } + + const owned = await prisma.medicationCategoryLabel.findFirst({ + where: { key, userId: user.id }, + select: { id: true }, + }); + if (!owned) return apiError("Category not found", 404); + + const updated = await prisma.medicationCategoryLabel.update({ + where: { id: owned.id }, + data: { + ...(parsed.data.label !== undefined + ? { labelEncrypted: encryptCategoryLabel(parsed.data.label) } + : {}), + ...(parsed.data.sortOrder !== undefined + ? { sortOrder: parsed.data.sortOrder } + : {}), + ...(parsed.data.isActive !== undefined + ? { isActive: parsed.data.isActive } + : {}), + }, + select: { + key: true, + labelEncrypted: true, + sortOrder: true, + isActive: true, + }, + }); + const medicationCount = await prisma.medicationCategoryAssignment.count({ + where: { category: key, medication: { userId: user.id } }, + }); + + await auditLog("medication.category.custom.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { key, fields: Object.keys(parsed.data) }, + }); + annotate({ + action: { name: "medication.category.custom.update" }, + meta: { key }, + }); + + return apiSuccess({ + key: updated.key, + label: decryptCategoryLabel(updated.labelEncrypted), + sortOrder: updated.sortOrder, + isActive: updated.isActive, + medicationCount, + }); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + if (await rateLimited(user.id)) { + return apiError("Too many requests, try again later", 429); + } + + const { key } = await params; + if (!isCustomMedicationCategoryKey(key)) { + return apiError("Category not found", 404); + } + + const result = await deleteMedicationCategoryLabel(user.id, key); + if (!result) return apiError("Category not found", 404); + + await auditLog("medication.category.custom.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { key, movedCount: result.movedCount }, + }); + annotate({ + action: { name: "medication.category.custom.delete" }, + meta: { key, movedCount: result.movedCount }, + }); + + return apiSuccess({ key, movedCount: result.movedCount }); + }, +); diff --git a/src/app/api/medications/categories/route.ts b/src/app/api/medications/categories/route.ts new file mode 100644 index 000000000..313012672 --- /dev/null +++ b/src/app/api/medications/categories/route.ts @@ -0,0 +1,126 @@ +/** + * v1.40 (#1041) — the person's own medication categories. + * + * GET /api/medications/categories — every custom category of the record, + * hidden ones included, in picker order, labels decrypted, each with + * the number of medications filed under it. + * POST /api/medications/categories — create one (`{ label }`). Mints a + * `custom:` key and encrypts the label at rest. Capped per + * account (hidden ones count). + * + * The label is the person's free text, so it never reaches a wide event or an + * audit row; only the key does. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + encryptCategoryLabel, + listMedicationCategoryLabels, + mintCustomMedicationCategoryKey, +} from "@/lib/medication-category"; +import { + MAX_CUSTOM_MEDICATION_CATEGORIES, + createMedicationCategoryLabelSchema, +} from "@/lib/validations/medication"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("read", "medications"); + const categories = await listMedicationCategoryLabels(user.id); + annotate({ + action: { name: "medication.category.custom.list" }, + meta: { count: categories.length }, + }); + return apiSuccess({ categories }); +}); + +/** + * Wrapped in `withIdempotency`: the key is minted fresh per request and the + * label is ciphertext with a per-write IV, so nothing else would catch a + * replayed create and the person's list would gain a silent duplicate. + */ +export const POST = apiHandler( + withIdempotency<[NextRequest]>(postMedicationCategory), +); + +async function postMedicationCategory(request: NextRequest): Promise { + const { user, actor } = await requireRecordAuth("manage", "medications"); + + // Keyed on the actor, the sharing precedent: a manager spends their own + // allowance and cannot lock the owner out of their own record. + const rl = await checkRateLimit( + `medications:category:custom:${actor.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + + const { data: raw, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createMedicationCategoryLabelSchema.safeParse(raw); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "medications.category.invalid", + }); + } + + const count = await prisma.medicationCategoryLabel.count({ + where: { userId: user.id }, + }); + if (count >= MAX_CUSTOM_MEDICATION_CATEGORIES) { + return apiError( + `Custom category limit reached (${MAX_CUSTOM_MEDICATION_CATEGORIES})`, + 422, + { errorCode: "medications.category.limitReached" }, + ); + } + + const created = await prisma.medicationCategoryLabel.create({ + data: { + userId: user.id, + key: mintCustomMedicationCategoryKey(), + labelEncrypted: encryptCategoryLabel(parsed.data.label), + sortOrder: count, + }, + select: { key: true, sortOrder: true, isActive: true }, + }); + + await auditLog("medication.category.custom.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { key: created.key }, + }); + annotate({ + action: { name: "medication.category.custom.create" }, + meta: { key: created.key }, + }); + + return apiSuccess( + { + key: created.key, + label: parsed.data.label, + sortOrder: created.sortOrder, + isActive: created.isActive, + medicationCount: 0, + }, + 201, + ); +} diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 0b148d2e7..8f93617a0 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -10,13 +10,17 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { createMedicationSchema } from "@/lib/validations/medication"; +import { + createMedicationSchema, + isCustomMedicationCategoryKey, +} from "@/lib/validations/medication"; import { unstableExternalIdMeta, unstableExternalIdShape, } from "@/lib/validations/external-id"; import { - getMedicationCategories, + isAssignableCategory, + resolveMedicationCategories, setMedicationCategory, } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; @@ -78,9 +82,11 @@ async function respondWithExistingMirror( }, ): Promise { let category = "OTHER"; + let categoryLabel: string | null = null; try { - const categories = await getMedicationCategories([medication.id]); - category = categories[medication.id] ?? "OTHER"; + const categories = await resolveMedicationCategories([medication.id]); + category = categories[medication.id]?.category ?? "OTHER"; + categoryLabel = categories[medication.id]?.categoryLabel ?? null; } catch { getEvent()?.addWarning("Medication categories could not be loaded"); } @@ -107,6 +113,7 @@ async function respondWithExistingMirror( ), ), category, + categoryLabel, }); } @@ -174,6 +181,16 @@ async function postMedication(request: NextRequest): Promise { trackIntake, } = parsed.data; + // v1.40 (#1041) — a custom category must be one of this record's own. + if ( + category !== undefined && + !(await isAssignableCategory(user.id, category)) + ) { + return apiError("Unknown medication category", 422, { + errorCode: "medications.category.unknown", + }); + } + // v1.28 — idempotent mirror create. A mirrored medication (Apple // Health) is keyed by the `(userId, externalSource, externalId)` // unique; a re-post of the same concept id (iOS re-sync, retry after a @@ -413,6 +430,10 @@ async function postMedication(request: NextRequest): Promise { medication.id, category, ); + const categoryLabel = isCustomMedicationCategoryKey(normalizedCategory) + ? ((await resolveMedicationCategories([medication.id]))[medication.id] + ?.categoryLabel ?? null) + : null; await auditLog("medication.create", { userId: user.id, @@ -446,6 +467,7 @@ async function postMedication(request: NextRequest): Promise { ), ), category: normalizedCategory, + categoryLabel, }, 201, ); diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index d66708448..99aa76753 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -24,6 +24,7 @@ import { Glp1SubstanceCurveSummary } from "@/components/insights/glp1-substance- import { SubPageShell } from "@/components/insights/sub-page-shell"; import { TileHeader } from "@/components/insights/tile-header"; import { apiGet } from "@/lib/api/api-fetch"; +import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; /** * v1.4.25 W4 — `/insights/medications`. @@ -44,7 +45,10 @@ interface MedicationEntry { id: string; name: string; dose: string; - category: "BLOOD_PRESSURE" | "VITAMIN" | "OTHER"; + /** A built-in category value or `custom:` (v1.40). */ + category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; compliance7: number; compliance30: number; streak: number; @@ -234,12 +238,11 @@ export default function InsightsMedikamentePage() { // card carried (7d/30d bars, taken/skipped/missed, heatmap, // per-med assistant sentence) stays — only the vertical rhythm // tightens. - const categoryLabel = - med.category === "BLOOD_PRESSURE" - ? t("medications.categoryBloodPressure") - : med.category === "VITAMIN" - ? t("medications.categoryVitamin") - : t("medications.categoryOther"); + const categoryLabel = getMedicationCategoryLabel( + med.category, + t, + med.categoryLabel, + ); return ( diff --git a/src/app/medications/__tests__/category-filter.test.tsx b/src/app/medications/__tests__/category-filter.test.tsx new file mode 100644 index 000000000..45c7785cb --- /dev/null +++ b/src/app/medications/__tests__/category-filter.test.tsx @@ -0,0 +1,140 @@ +/** + * v1.40 (#1041) — the category filter on /medications and the custom + * category badge. The filter appears once the list spans two categories + * (or the record has categories of its own), offers each category in use, + * and the card badge shows a custom category's own label. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + useSearchParams: () => new URLSearchParams(""), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + isLoading: false, + user: { timezone: "Europe/Berlin" }, + }), +})); + +// The create wizard and the dialogs are closed in every assertion here; +// mock them away so the page render stays scoped to the list surface. +vi.mock("@/components/medications/wizard/medication-wizard-dialog", () => ({ + MedicationWizardDialog: () => null, +})); +vi.mock("@/components/medications/log-intake-dialog", () => ({ + LogIntakeDialog: () => null, +})); + +// v1.30.x — `@/app/medications/page` is now an async RSC prefetch wrapper; the +// interactive surface this test renders is the client leaf it wraps. +import MedicationsPage from "@/app/medications/page-client"; +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { MedicationListLayout } from "@/lib/medication-list-layout"; + +const pastWindow = { + windowStart: "01:00", + windowEnd: "02:00", + label: null, + daysOfWeek: null, + dose: null, +}; + +const KEY = "custom:11111111-1111-4111-8111-111111111111"; + +function med(id: string, name: string, category: string, label?: string) { + return { + id, + name, + dose: "5 mg", + category, + categoryLabel: label ?? null, + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + nextDueAt: null, + nextDueOverdue: false, + stockDosesRemaining: null, + schedules: [{ id: `s-${id}`, ...pastWindow }], + }; +} + +function renderPage( + meds: ReturnType[], + customCategories: unknown[] = [], +): string { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + client.setQueryData(queryKeys.medications(), meds); + client.setQueryData(queryKeys.medicationListLayout(), { + version: 1, + view: "cards", + order: [], + } satisfies MedicationListLayout); + client.setQueryData(queryKeys.medicationCategories(), { + categories: customCategories, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-02T12:00:00Z")); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("/medications — category filter", () => { + it("shows the filter once the list spans two categories, with the custom label on the card", () => { + const html = renderPage([ + med("m1", "Levothyroxine", "THYROID"), + med("m2", "Tamiflu", KEY, "Travel kit"), + ]); + expect(html).toContain('data-slot="filter-bar"'); + expect(html).toContain('data-slot="filter-bar-pill"'); + // The card badge reads the person's own label, not "Other". + expect(html).toContain("Travel kit"); + }); + + it("leaves the filter out when every medication shares one category and none are custom", () => { + const html = renderPage([ + med("m1", "Levothyroxine", "THYROID"), + med("m2", "Liothyronine", "THYROID"), + ]); + expect(html).not.toContain('data-slot="filter-bar"'); + }); + + it("shows the filter, with its manage entry, when the record has categories of its own", () => { + const html = renderPage( + [med("m1", "Levothyroxine", "THYROID")], + [ + { + key: KEY, + label: "Travel kit", + sortOrder: 0, + isActive: true, + medicationCount: 0, + }, + ], + ); + expect(html).toContain('data-slot="filter-bar"'); + expect(html).toContain('data-slot="medication-categories-manage"'); + }); +}); diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 23df5de21..d9331fdcc 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -1,7 +1,7 @@ "use client"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -33,6 +33,15 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { EmptyState } from "@/components/ui/empty-state"; +import { FilterBar, FilterBarSelect } from "@/components/ui/filter-bar"; +import { MedicationCategoriesSheet } from "@/components/medications/medication-categories-sheet"; +import { useMedicationCategories } from "@/components/medications/use-medication-categories"; +import { + ALL_MEDICATION_CATEGORIES, + effectiveCategoryFilter as resolveCategoryFilter, + filterMedicationsByCategory, + medicationCategoryOptions, +} from "@/lib/medications/category-filter"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -74,6 +83,8 @@ interface Medication { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; /** v1.4.25 W4d — Prisma treatment class (GENERIC | GLP1). */ treatmentClass?: string; /** v1.4.25 W4d — doses per pen/vial for inventory math. */ @@ -169,6 +180,63 @@ function MedicationCardSkeleton() { ); } +/** + * v1.40 (#1041) — the category filter is remembered for the browser session + * only, by design: no preference column, and a new visit starts on All. + */ +const CATEGORY_FILTER_STORAGE_KEY = "healthlog.medications.categoryFilter"; +const ALL_CATEGORIES = ALL_MEDICATION_CATEGORIES; + +const CATEGORY_FILTER_EVENT = "healthlog:medication-category-filter"; + +function readStoredCategoryFilter(): string { + try { + return ( + window.sessionStorage.getItem(CATEGORY_FILTER_STORAGE_KEY) ?? + ALL_CATEGORIES + ); + } catch { + return ALL_CATEGORIES; + } +} + +function storeCategoryFilter(value: string) { + try { + if (value === ALL_CATEGORIES) { + window.sessionStorage.removeItem(CATEGORY_FILTER_STORAGE_KEY); + } else { + window.sessionStorage.setItem(CATEGORY_FILTER_STORAGE_KEY, value); + } + } catch { + // Storage blocked: the pick still applies until the page unmounts. + } + window.dispatchEvent(new Event(CATEGORY_FILTER_EVENT)); +} + +function subscribeCategoryFilter(onChange: () => void): () => void { + window.addEventListener(CATEGORY_FILTER_EVENT, onChange); + return () => window.removeEventListener(CATEGORY_FILTER_EVENT, onChange); +} + +/** + * The stored pick, read through `useSyncExternalStore` so the server render + * (and the first client render) is All and the stored pick follows without a + * hydration mismatch. Blocked storage degrades to a per-view filter. + */ +function useCategoryFilter(): [string, (value: string) => void] { + const [fallback, setFallback] = useState(null); + const stored = useSyncExternalStore( + subscribeCategoryFilter, + readStoredCategoryFilter, + () => ALL_CATEGORIES, + ); + const set = useCallback((value: string) => { + setFallback(value); + storeCategoryFilter(value); + }, []); + return [fallback ?? stored, set]; +} + export default function MedicationsPageClient() { const { user, isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); @@ -213,6 +281,15 @@ export default function MedicationsPageClient() { // v1.16.11 (#316) — "Alle fälligen einnehmen" confirm dialog. The header // button earns its slot only while ≥ 2 medications are currently due. const [takeAllOpen, setTakeAllOpen] = useState(false); + // v1.40 (#1041) — the category filter and the manage sheet it opens. + const [categoryFilter, setCategoryFilter] = useCategoryFilter(); + const [manageCategoriesOpen, setManageCategoriesOpen] = useState(false); + // Renaming, hiding and deleting a category stay with the owner (the edit + // route is owner-only, like the cycle symptom vocabulary's). + const canManageCategories = !inSharedRecord; + const customCategories = useMedicationCategories({ + enabled: isAuthenticated && medicationsEnabled, + }); // v1.16.10 — the persisted list presentation: cards vs table plus the // manual medication order, server-side per user @@ -382,6 +459,19 @@ export default function MedicationsPageClient() { // Defensive against stale service-worker responses or any future API // shape change: only filter when we actually have an array. const medsArray = Array.isArray(medications) ? medications : []; + // v1.40 (#1041) — the categories in use, each once, labelled the way the + // card badge labels it. The filter offers only these, so a pick can never + // empty the list. + const categoriesInUse = medicationCategoryOptions(medsArray, t); + const effectiveCategoryFilter = resolveCategoryFilter( + categoryFilter, + categoriesInUse, + ); + const hasCustomCategories = (customCategories.data ?? []).length > 0; + const showCategoryFilter = + medsArray.length > 0 && + (categoriesInUse.length >= 2 || hasCustomCategories); + // v1.16.10 — the user-defined manual order applies to BOTH views // (cards and table). Medications not in the saved order keep the // alphabetical default, appended after the ordered block. @@ -394,6 +484,16 @@ export default function MedicationsPageClient() { layout.order, ); const tableView = layout.view === "table"; + // The filter narrows what the list shows; the due set and the log-intake + // dialog below keep reading every medication. + const shownActiveMeds = filterMedicationsByCategory( + activeMeds, + effectiveCategoryFilter, + ); + const shownInactiveMeds = filterMedicationsByCategory( + inactiveMeds, + effectiveCategoryFilter, + ); // v1.16.11 (#316) — the currently-due set, derived from the list payload // the page already holds via the SAME pipeline a card's pill runs (band @@ -493,6 +593,34 @@ export default function MedicationsPageClient() { } /> + {showCategoryFilter && !isLoading && !isError && ( + setCategoryFilter(ALL_CATEGORIES)} + > + + {canManageCategories && ( + + )} + + )} + {isLoading || isLayoutLoading ? ( // v1.11.3 C4 — skeletons instead of a bare centred spinner so the // page reserves the loaded layout and does not jump when the @@ -540,17 +668,17 @@ export default function MedicationsPageClient() { // gating, same mutation hooks as the cards; one row per // medication, inactive rows pinned after the active block. ) : (
{/* Active medications */} - {activeMeds.length > 0 && ( + {shownActiveMeds.length > 0 && (
- {activeMeds.map((med) => + {shownActiveMeds.map((med) => // v1.16.11 — an as-needed medication always renders the // generic card: the GLP-1 variant is built around the // rolling injection cadence an as-needed med doesn't have. @@ -577,13 +705,13 @@ export default function MedicationsPageClient() { )} {/* Inactive medications */} - {inactiveMeds.length > 0 && ( + {shownInactiveMeds.length > 0 && (

- {t("common.inactive")} ({inactiveMeds.length}) + {t("common.inactive")} ({shownInactiveMeds.length})

- {inactiveMeds.map((med) => + {shownInactiveMeds.map((med) => med.treatmentClass === "GLP1" && !med.asNeeded ? ( )} + {manageCategoriesOpen && canManageCategories && ( + + )} + {logIntakeOpen && ( ); - const categoryLabel = getMedicationCategoryLabel(medication.category, t); + const categoryLabel = getMedicationCategoryLabel( + medication.category, + t, + medication.categoryLabel, + ); // The upcoming-injection line value. The card owns this VALUE content — // the liked relative-day phrasing ("Samstag 13.7. (in 7 Tagen)") — while diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 577e171a1..edd97d6bb 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -63,6 +63,8 @@ interface Medication { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; /** * v1.4.25 W4d — Prisma treatment-class discriminator. When set to * "GLP1" the parent should render the {@link Glp1MedicationCard} @@ -254,7 +256,11 @@ export function MedicationCard({ // background/border tint. Defaults to "upcoming" (calm) when the display // block is absent (older mocks). const doseStatus = display?.currentDose.status ?? "upcoming"; - const categoryLabel = getMedicationCategoryLabel(medication.category, t); + const categoryLabel = getMedicationCategoryLabel( + medication.category, + t, + medication.categoryLabel, + ); const sortedSchedules = [...medication.schedules].sort( (a, b) => a.windowStart.localeCompare(b.windowStart) || diff --git a/src/components/medications/medication-categories-sheet.tsx b/src/components/medications/medication-categories-sheet.tsx new file mode 100644 index 000000000..4e438ca63 --- /dev/null +++ b/src/components/medications/medication-categories-sheet.tsx @@ -0,0 +1,285 @@ +"use client"; + +/** + * v1.40 (#1041) — manage the person's own medication categories: rename, + * hide from the picker, delete, add. Opens from the category filter on the + * medications list. Rows follow the mood manager's archived-tag row anatomy; + * the delete confirmation names how many medications move to Other. + */ +import { Eye, EyeOff, Loader2, Pencil, Tag, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; + +import { AddMedicationCategoryRow } from "./medication-category-add-row"; +import { + type MedicationCategoryLabelDTO, + useDeleteMedicationCategory, + useMedicationCategories, + useUpdateMedicationCategory, +} from "./use-medication-categories"; + +function CategoryRow({ + category, + onDelete, +}: { + category: MedicationCategoryLabelDTO; + onDelete: (category: MedicationCategoryLabelDTO) => void; +}) { + const { t } = useTranslations(); + const update = useUpdateMedicationCategory(); + const [editing, setEditing] = useState(false); + const [label, setLabel] = useState(category.label); + + async function rename() { + const trimmed = label.trim(); + if (!trimmed || trimmed === category.label) { + setEditing(false); + return; + } + try { + await update.mutateAsync({ key: category.key, label: trimmed }); + setEditing(false); + } catch { + toast.error(t("medications.category.custom.saveFailed")); + } + } + + async function toggleHidden() { + try { + await update.mutateAsync({ + key: category.key, + isActive: !category.isActive, + }); + } catch { + toast.error(t("medications.category.custom.saveFailed")); + } + } + + if (editing) { + return ( +
+ setLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void rename(); + } + }} + /> +
+ + +
+
+ ); + } + + return ( +
+
+ ); +} + +export function MedicationCategoriesSheet({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + const categories = useMedicationCategories(); + const remove = useDeleteMedicationCategory(); + const [pendingDelete, setPendingDelete] = + useState(null); + + async function confirmDelete(category: MedicationCategoryLabelDTO) { + try { + const result = await remove.mutateAsync(category.key); + toast.success( + t("medications.category.custom.movedToOther", { + count: String(result.movedCount), + }), + ); + } catch { + toast.error(t("medications.category.custom.saveFailed")); + } + } + + return ( + +
+ {categories.isError ? ( + void categories.refetch()} /> + ) : ( + (categories.data ?? []).map((category) => ( + + )) + )} + +

+ {t("medications.category.custom.bpGateNote")} +

+
+ + { + if (!next) setPendingDelete(null); + }} + > + + + + {t("medications.category.custom.deleteTitle")} + + + {t("medications.category.custom.deleteBody", { + count: String(pendingDelete?.medicationCount ?? 0), + })} + + + + {t("common.cancel")} + { + const category = pendingDelete; + setPendingDelete(null); + if (category) void confirmDelete(category); + }} + > + {t("medications.category.custom.delete")} + + + + +
+ ); +} diff --git a/src/components/medications/medication-category-add-row.tsx b/src/components/medications/medication-category-add-row.tsx new file mode 100644 index 000000000..1fad4ed6d --- /dev/null +++ b/src/components/medications/medication-category-add-row.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Loader2, Plus } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useTranslations } from "@/lib/i18n/context"; + +import { + isCategoryLimitError, + useCreateMedicationCategory, +} from "./use-medication-categories"; + +/** + * v1.40 (#1041) — the inline "New category" row: a dashed row (the cycle + * log's add-symptom pattern) that opens into a label field in place, so + * creating a category never leaves the wizard or the manage sheet. The + * caller hears the new key as soon as the category exists. + */ +export function AddMedicationCategoryRow({ + onCreated, +}: { + onCreated?: (key: string) => void; +}) { + const { t } = useTranslations(); + const create = useCreateMedicationCategory(); + const [open, setOpen] = useState(false); + const [label, setLabel] = useState(""); + + function close() { + setOpen(false); + setLabel(""); + create.reset(); + } + + async function submit() { + const trimmed = label.trim(); + if (!trimmed) return; + try { + const created = await create.mutateAsync({ label: trimmed }); + onCreated?.(created.key); + close(); + } catch { + // `create.error` drives the inline message below. + } + } + + if (!open) { + return ( + + ); + } + + return ( +
+ setLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + close(); + } + }} + /> + {create.error ? ( +

+ {isCategoryLimitError(create.error) + ? t("medications.category.custom.limitReached") + : t("medications.category.custom.saveFailed")} +

+ ) : null} +
+ + +
+
+ ); +} diff --git a/src/components/medications/use-medication-categories.ts b/src/components/medications/use-medication-categories.ts new file mode 100644 index 000000000..57a197ca9 --- /dev/null +++ b/src/components/medications/use-medication-categories.ts @@ -0,0 +1,97 @@ +"use client"; + +/** + * v1.40 (#1041) — the record's own medication categories: read, create, + * rename / hide, delete. A rename or a delete changes what every card and the + * insights medication tiles show (a delete moves medications to Other), so + * both refetch the medication reads and the insights reads. Neither touches a + * dose, so the daily reads the intake bundle refreshes stay as they are. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + ApiError, + apiDelete, + apiGet, + apiPatch, + apiPost, +} from "@/lib/api/api-fetch"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; + +const CATEGORY_DEPENDENT_KEYS = [ + queryKeys.medications(), + queryKeys.insightsRoot(), +] as const; + +export interface MedicationCategoryLabelDTO { + key: string; + label: string; + sortOrder: number; + isActive: boolean; + medicationCount: number; +} + +/** The `errorCode` the create POST answers when the per-account cap is hit. */ +export const MEDICATION_CATEGORY_LIMIT_ERROR_CODE = + "medications.category.limitReached"; + +/** Whether a failed create hit the cap rather than anything transient. */ +export function isCategoryLimitError(error: unknown): boolean { + return ( + error instanceof ApiError && + error.meta?.errorCode === MEDICATION_CATEGORY_LIMIT_ERROR_CODE + ); +} + +export function useMedicationCategories(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: queryKeys.medicationCategories(), + queryFn: () => + apiGet<{ categories: MedicationCategoryLabelDTO[] }>( + "/api/medications/categories", + ), + staleTime: 5 * 60_000, + select: (data) => data.categories, + }); +} + +export function useCreateMedicationCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { label: string }) => + apiPost("/api/medications/categories", input), + onSuccess: () => + qc.invalidateQueries({ queryKey: queryKeys.medicationCategories() }), + }); +} + +export function useUpdateMedicationCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { + key: string; + label?: string; + isActive?: boolean; + sortOrder?: number; + }) => { + const { key, ...body } = input; + return apiPatch( + `/api/medications/categories/${encodeURIComponent(key)}`, + body, + ); + }, + onSuccess: () => invalidateKeys(qc, CATEGORY_DEPENDENT_KEYS), + }); +} + +export function useDeleteMedicationCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (key: string) => + apiDelete<{ key: string; movedCount: number }>( + `/api/medications/categories/${encodeURIComponent(key)}`, + ), + onSuccess: () => invalidateKeys(qc, CATEGORY_DEPENDENT_KEYS), + }); +} diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 9de116879..f8edcbfa3 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -1330,3 +1330,85 @@ describe("category on edit (v1.39.4)", () => { expect(body.category).toBe("SKIN"); }); }); + +describe("custom categories (v1.40, #1041)", () => { + const KEY = "custom:11111111-1111-4111-8111-111111111111"; + const OTHER_KEY = "custom:22222222-2222-4222-8222-222222222222"; + + function hydrate(category: string, treatmentClass = "GENERIC") { + return hydrateWizardPayload({ + id: "m1", + name: "Tamiflu", + dose: "75 mg", + category, + treatmentClass, + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [ + { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + }, + ], + }); + } + + it("hydrates a custom category onto the Other row with its key", () => { + const payload = hydrate(KEY); + expect(payload.treatmentRow).toBe("other"); + expect(payload.customCategoryKey).toBe(KEY); + }); + + it("keeps the custom category on an edit that leaves it alone", () => { + const body = buildCreateBody({ ...hydrate(KEY), name: "Renamed" }, "edit"); + expect("category" in body).toBe(false); + }); + + it("sends the new key when the person picks another custom category", () => { + const body = buildCreateBody( + { ...hydrate(KEY), customCategoryKey: OTHER_KEY }, + "edit", + ); + expect(body.category).toBe(OTHER_KEY); + expect(body.treatmentClass).toBe("GENERIC"); + }); + + it("sends the built-in value when the person moves it back to a built-in row", () => { + const body = buildCreateBody( + { ...hydrate(KEY), treatmentRow: "vitamin", customCategoryKey: null }, + "edit", + ); + expect(body.category).toBe("VITAMIN"); + }); + + it("sends the built-in Other when the person picks the Other row itself", () => { + const body = buildCreateBody( + { ...hydrate(KEY), customCategoryKey: null }, + "edit", + ); + expect(body.category).toBe("OTHER"); + }); + + it("files a new medication under a custom category on create", () => { + const body = buildCreateBody( + { + ...hydrate("VITAMIN"), + treatmentRow: "other", + customCategoryKey: KEY, + }, + "create", + ); + expect(body.category).toBe(KEY); + expect(body.treatmentClass).toBe("GENERIC"); + }); + + it("keeps a GLP-1 medication on the GLP-1 row whatever its category", () => { + const payload = hydrate(KEY, "GLP1"); + expect(payload.treatmentRow).toBe("glp1"); + expect(payload.customCategoryKey).toBeNull(); + }); +}); diff --git a/src/components/medications/wizard/steps/step2-class.tsx b/src/components/medications/wizard/steps/step2-class.tsx index 1337cbbbf..f066cbb88 100644 --- a/src/components/medications/wizard/steps/step2-class.tsx +++ b/src/components/medications/wizard/steps/step2-class.tsx @@ -21,8 +21,12 @@ import { } from "lucide-react"; import type { ComponentType } from "react"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; +import { AddMedicationCategoryRow } from "../../medication-category-add-row"; +import { useMedicationCategories } from "../../use-medication-categories"; + import { type WizardTreatmentRow, WIZARD_TREATMENT_ROWS, @@ -48,8 +52,60 @@ const ROW_ICONS: Record> = { other: Tag as unknown as LucideIcon, }; +/** One selectable row: the same anatomy for built-in and custom categories. */ +function ClassRow({ + value, + label, + Icon, + selected, + onSelect, + slot, +}: { + value: string; + label: string; + Icon: ComponentType; + selected: boolean; + onSelect: () => void; + slot: "wizard-class-row" | "wizard-custom-class-row"; +}) { + return ( + + ); +} + export function Step2Class({ payload, applyPartial }: StepProps) { const { t } = useTranslations(); + const categories = useMedicationCategories(); + // Creating a category is admitted at MANAGE in a shared record. + const { canManageDomain } = useRecordCapabilities(); + const customKey = payload.customCategoryKey ?? null; + // Hidden categories leave the picker, except the one this medication is + // already filed under: an edit must be able to show what it has. + const customRows = (categories.data ?? []).filter( + (c) => c.isActive || c.key === customKey, + ); return (
- {WIZARD_TREATMENT_ROWS.map((row) => { - const Icon = ROW_ICONS[row]; - const selected = payload.treatmentRow === row; - const label = t(`medications.wizard.classRow.${row}`); - return ( - - ); - })} + {WIZARD_TREATMENT_ROWS.map((row) => ( + + applyPartial( + // The GLP-1 row is always injected; nudge the + // delivery form so the common case needs no extra + // tap. The user can still flip it on Step 3. + row === "glp1" + ? { + treatmentRow: row, + customCategoryKey: null, + deliveryForm: "INJECTION", + } + : { treatmentRow: row, customCategoryKey: null }, + ) + } + /> + ))} + {customRows.map((c) => ( + + applyPartial({ treatmentRow: "other", customCategoryKey: c.key }) + } + /> + ))} + {canManageDomain("medications") && ( + + applyPartial({ treatmentRow: "other", customCategoryKey: key }) + } + /> + )} +

+ {t("medications.category.custom.bpGateNote")} +

); } diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 498e7c685..5c08230d1 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -192,6 +192,24 @@ export function rowFromTreatment( } } +/** + * v1.40 (#1041) — the custom category an edit hydrates to. A GLP-1 + * medication is the GLP-1 row whatever its category says, as above. + */ +function customCategoryFields( + treatmentClass: string | undefined, + category: string | undefined, +): { + customCategoryKey: string | null; + initialCustomCategoryKey: string | null; +} { + const key = + treatmentClass !== "GLP1" && category?.startsWith("custom:") + ? category + : null; + return { customCategoryKey: key, initialCustomCategoryKey: key }; +} + // ──────────────────────────────────────────────────────────────────── // Wizard payload + per-schedule draft // ──────────────────────────────────────────────────────────────────── @@ -257,6 +275,15 @@ export interface WizardPayload { * Absent on create. */ initialTreatmentRow?: WizardTreatmentRow | null; + /** + * v1.40 (#1041) — one of the person's own categories, picked from the + * custom rows under the built-in ones. Set together with `treatmentRow: + * "other"` (the treatment class a custom category files under); null when + * a built-in row is picked. + */ + customCategoryKey?: string | null; + /** v1.40 — the custom category the edit hydrated to, for the same reason. */ + initialCustomCategoryKey?: string | null; /** * v1.6.0 — route of administration. Drives the injection-site * rotation preview (shown when `INJECTION`) and, paired with @@ -628,8 +655,12 @@ export function firstInvalidIndex( export interface CreateMedicationBody { name: string; dose: string; - /** Omitted on an edit that left the category row untouched (v1.39.4). */ - category?: MedicationCategoryValue; + /** + * Omitted on an edit that left the category row untouched (v1.39.4). A + * built-in value or, since v1.40, the key of one of the person's own + * categories. + */ + category?: MedicationCategoryValue | `custom:${string}`; treatmentClass?: MedicationTreatmentClass; /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ deliveryForm: MedicationDeliveryForm; @@ -843,16 +874,21 @@ export function buildCreateBody( // v1.39.4 — an edit that did not touch the category row leaves the stored // category (and treatment class) alone rather than re-deriving them from // the row, which cannot represent every stored value. + const customCategoryKey = committed.customCategoryKey ?? null; const keepStoredCategory = forMode === "edit" && committed.initialTreatmentRow !== undefined && - committed.treatmentRow === committed.initialTreatmentRow; + committed.treatmentRow === committed.initialTreatmentRow && + customCategoryKey === (committed.initialCustomCategoryKey ?? null); const body: CreateMedicationBody = { name: committed.name.trim(), dose, ...(!keepStoredCategory && { - category: mapping.category, - treatmentClass: mapping.treatmentClass, + // v1.40 — a custom row files under the person's own category with the + // generic treatment class, like the built-in Other row. + category: (customCategoryKey ?? + mapping.category) as CreateMedicationBody["category"], + treatmentClass: customCategoryKey ? "GENERIC" : mapping.treatmentClass, }), deliveryForm: committed.deliveryForm, ...(Number.isFinite(parsedDosesPerUnit) && @@ -1092,6 +1128,8 @@ export interface MedicationPayload { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; treatmentClass?: string; /** v1.6.0 — route of administration. Defaults to ORAL when absent. */ deliveryForm?: string; @@ -1239,6 +1277,7 @@ export function hydrateWizardPayload( initial.treatmentClass, initial.category, ), + ...customCategoryFields(initial.treatmentClass, initial.category), deliveryForm: normaliseDeliveryForm(initial.deliveryForm), dosesPerUnit: typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1 diff --git a/src/lib/__tests__/medication-category.test.ts b/src/lib/__tests__/medication-category.test.ts new file mode 100644 index 000000000..6d49fd106 --- /dev/null +++ b/src/lib/__tests__/medication-category.test.ts @@ -0,0 +1,179 @@ +/** + * v1.40 (#1041) — the one helper that resolves and writes a medication's + * category. Prisma is stubbed; the real-database contract lives in + * `tests/integration/medication-custom-categories.test.ts`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + assignmentFindMany: vi.fn(), + assignmentUpsert: vi.fn(), + assignmentUpdateMany: vi.fn(), + labelFindMany: vi.fn(), + labelFindFirst: vi.fn(), + labelDelete: vi.fn(), + medicationFindUnique: vi.fn(), + annotate: vi.fn(), + order: [] as string[], +})); + +vi.mock("@/lib/db", () => { + const client = { + medicationCategoryAssignment: { + findMany: mocks.assignmentFindMany, + upsert: mocks.assignmentUpsert, + updateMany: mocks.assignmentUpdateMany, + }, + medicationCategoryLabel: { + findMany: mocks.labelFindMany, + findFirst: mocks.labelFindFirst, + delete: mocks.labelDelete, + }, + medication: { findUnique: mocks.medicationFindUnique }, + }; + return { + prisma: { + ...client, + $transaction: async (fn: (tx: typeof client) => unknown) => fn(client), + }, + }; +}); +vi.mock("@/lib/logging/context", () => ({ annotate: mocks.annotate })); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => new TextEncoder().encode(`sealed:${s}`), + decryptFromBytes: (b: Uint8Array) => + new TextDecoder().decode(b).replace(/^sealed:/, ""), +})); + +import { + deleteMedicationCategoryLabel, + isAssignableCategory, + resolveMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; + +const OWN = "custom:11111111-1111-4111-8111-111111111111"; +const FOREIGN = "custom:22222222-2222-4222-8222-222222222222"; +const GONE = "custom:33333333-3333-4333-8333-333333333333"; +const sealed = (s: string) => new TextEncoder().encode(`sealed:${s}`); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.order.length = 0; +}); + +describe("resolveMedicationCategories", () => { + it("resolves built-ins, the owner's own key with its label, and nothing else", async () => { + mocks.assignmentFindMany.mockResolvedValue([ + { medicationId: "m1", category: "THYROID", medication: { userId: "u1" } }, + { medicationId: "m2", category: OWN, medication: { userId: "u1" } }, + { medicationId: "m3", category: FOREIGN, medication: { userId: "u1" } }, + { medicationId: "m4", category: GONE, medication: { userId: "u1" } }, + { + medicationId: "m5", + category: "NOT_A_VALUE", + medication: { userId: "u1" }, + }, + ]); + mocks.labelFindMany.mockResolvedValue([ + { key: OWN, userId: "u1", labelEncrypted: sealed("Travel kit") }, + { key: FOREIGN, userId: "u2", labelEncrypted: sealed("Theirs") }, + ]); + + const result = await resolveMedicationCategories([ + "m1", + "m2", + "m3", + "m4", + "m5", + "m6", + ]); + + expect(result).toEqual({ + m1: { category: "THYROID", categoryLabel: null }, + m2: { category: OWN, categoryLabel: "Travel kit" }, + // Another account's label never resolves, even by its exact key. + m3: { category: "OTHER", categoryLabel: null }, + m4: { category: "OTHER", categoryLabel: null }, + m5: { category: "OTHER", categoryLabel: null }, + // No row at all reads as OTHER. + m6: { category: "OTHER", categoryLabel: null }, + }); + // The two custom keys that resolved to nothing are counted, not thrown. + expect(mocks.annotate).toHaveBeenCalledWith({ + meta: { "medication.category.dangling": 2 }, + }); + }); + + it("skips the label read when no custom key is in play", async () => { + mocks.assignmentFindMany.mockResolvedValue([ + { medicationId: "m1", category: "VITAMIN", medication: { userId: "u1" } }, + ]); + await resolveMedicationCategories(["m1"]); + expect(mocks.labelFindMany).not.toHaveBeenCalled(); + expect(mocks.annotate).not.toHaveBeenCalled(); + }); +}); + +describe("isAssignableCategory / setMedicationCategory", () => { + it("admits built-ins and the owner's own key only", async () => { + mocks.labelFindFirst.mockImplementation( + async ({ where }: { where: { key: string; userId: string } }) => + where.key === OWN && where.userId === "u1" ? { key: OWN } : null, + ); + expect(await isAssignableCategory("u1", "MENTAL_HEALTH")).toBe(true); + expect(await isAssignableCategory("u1", OWN)).toBe(true); + expect(await isAssignableCategory("u1", FOREIGN)).toBe(false); + expect(await isAssignableCategory("u1", "custom")).toBe(false); + expect(await isAssignableCategory("u1", "PIZZA")).toBe(false); + }); + + it("writes OTHER for a key the medication's owner does not hold", async () => { + mocks.medicationFindUnique.mockResolvedValue({ userId: "u1" }); + mocks.labelFindFirst.mockResolvedValue(null); + const written = await setMedicationCategory("m1", FOREIGN); + expect(written).toBe("OTHER"); + expect(mocks.assignmentUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: { medicationId: "m1", category: "OTHER" }, + update: { category: "OTHER" }, + }), + ); + }); + + it("writes the owner's own key", async () => { + mocks.medicationFindUnique.mockResolvedValue({ userId: "u1" }); + mocks.labelFindFirst.mockResolvedValue({ key: OWN }); + expect(await setMedicationCategory("m1", OWN)).toBe(OWN); + }); +}); + +describe("deleteMedicationCategoryLabel", () => { + it("moves the category's medications to OTHER before the label goes", async () => { + mocks.labelFindFirst.mockResolvedValue({ id: "label-1" }); + mocks.assignmentUpdateMany.mockImplementation(async () => { + mocks.order.push("move"); + return { count: 3 }; + }); + mocks.labelDelete.mockImplementation(async () => { + mocks.order.push("delete"); + return {}; + }); + + const result = await deleteMedicationCategoryLabel("u1", OWN); + + expect(result).toEqual({ movedCount: 3 }); + expect(mocks.assignmentUpdateMany).toHaveBeenCalledWith({ + where: { category: OWN, medication: { userId: "u1" } }, + data: { category: "OTHER" }, + }); + expect(mocks.order).toEqual(["move", "delete"]); + }); + + it("answers null and touches nothing for a key that is not the caller's", async () => { + mocks.labelFindFirst.mockResolvedValue(null); + expect(await deleteMedicationCategoryLabel("u1", FOREIGN)).toBeNull(); + expect(mocks.assignmentUpdateMany).not.toHaveBeenCalled(); + expect(mocks.labelDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 67d37b102..6206b7254 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -210,10 +210,11 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ kind: "string", }, - // ───── Custom labels (mood + cycle) ───── + // ───── Custom labels (mood, cycle, medication categories) ───── { model: "MoodTag", field: "labelEncrypted", kind: "string" }, { model: "MoodTagCategory", field: "labelEncrypted", kind: "string" }, { model: "CycleSymptom", field: "labelEncrypted", kind: "string" }, + { model: "MedicationCategoryLabel", field: "labelEncrypted", kind: "bytes" }, // ───── Notification + push secrets ───── { model: "NotificationChannel", field: "config", kind: "string" }, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 60512c4cf..ad8a4f43e 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -73,6 +73,9 @@ export const WIPE_MODELS = [ "MedicationInventoryItem", "MedicationIntakeImportJob", "MedicationComplianceRollup", + // The account's own category names (v1.40, #1041). No child rows; the + // medications that name one go with their own entry. + "MedicationCategoryLabel", // ── Mood ──────────────────────────────────────────────────────────────── // The day context, before the entry it hangs off: it cascades either way, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 0471bf2d9..351b9ff23 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ // through it is proven in tests/integration/backup-round-trip.test.ts. vi.mock("@/lib/medication-category", () => ({ getMedicationCategories: vi.fn(async () => ({})), + readMedicationCategoryLabelsForBackup: vi.fn(async () => []), setMedicationCategory: vi.fn(async () => "OTHER"), })); vi.mock("@/lib/export/paged-measurements", () => ({ diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index d95ed71c8..3a79e66f2 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -35,6 +35,9 @@ const mocks = vi.hoisted(() => ({ // through it is proven in tests/integration/backup-round-trip.test.ts. vi.mock("@/lib/medication-category", () => ({ getMedicationCategories: vi.fn(async () => ({})), + readMedicationCategoryLabelsForBackup: vi.fn(async () => []), + encryptCategoryLabel: vi.fn(() => new Uint8Array()), + mintCustomMedicationCategoryKey: vi.fn(() => "custom:minted"), setMedicationCategory: vi.fn(async () => "OTHER"), })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index a49e803ff..14ebbb35f 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -83,6 +83,9 @@ export const BACKED_UP_MODELS = [ // formats and is written back in the restore transaction. Without it every // restored medication reads as Other. "MedicationCategoryAssignment", + // The categories the person named themselves (v1.40, #1041). A medication + // filed under one carries its key; without the row the key reads as Other. + "MedicationCategoryLabel", // ── Mood ────────────────────────────────────────────────────────────────── "MoodEntry", @@ -310,6 +313,7 @@ export const TWO_ENDED_MODELS = [ "MedicationPauseEra", "MedicationDoseChange", "MedicationCategoryAssignment", + "MedicationCategoryLabel", "MoodEntry", "MoodContext", "MoodEntryTagLink", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index aaec42083..63892829f 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -122,7 +122,10 @@ import { buildAccountSettingsBackupSection, type AccountSettingsBackupSection, } from "@/lib/export/account-settings-backup"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { + getMedicationCategories, + readMedicationCategoryLabelsForBackup, +} from "@/lib/medication-category"; export interface FullBackupCounts extends @@ -970,6 +973,12 @@ export async function buildFullBackupPayload( medications.map((m) => m.id), prisma, ); + // v1.40 (#1041) — the account's own categories, restored before the + // medications that name them by key. Labels travel decrypted so a file + // restores on an instance with a different key; every one is carried, + // hidden ones included, because a medication can still be filed under it. + const customMedicationCategories = + await readMedicationCategoryLabelsForBackup(userId, prisma); // Where each archived era sits in its OWN drug's ordered list. Built once // here rather than per row, and scoped per medication because the supersede @@ -1001,6 +1010,7 @@ export async function buildFullBackupPayload( : null, ...accountSettingsSection, measurements: bulk.measurements, + customMedicationCategories, medications: medications.map((m) => ({ ...(disasterRecovery ? { diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 0dbfb4bd2..f7299abac 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -107,7 +107,11 @@ import { restoreAwardsData } from "@/lib/export/awards-backup"; import { restoreEnvironmentData } from "@/lib/export/environment-backup"; import { restoreEcgData } from "@/lib/export/ecg-backup"; import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at"; -import { setMedicationCategory } from "@/lib/medication-category"; +import { + encryptCategoryLabel, + mintCustomMedicationCategoryKey, + setMedicationCategory, +} from "@/lib/medication-category"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; import { stampSyncReset } from "@/lib/sync/reset"; @@ -1049,6 +1053,34 @@ export async function restoreBackup( // is where an operator has to go to see what the position meant, and // an id would be one the restore never wrote. const unresolvedRevisionLinks: string[] = []; + // v1.40 (#1041) — the account's own medication categories, before + // the medications that name them by key. Delete-then-recreate like + // the medications themselves. A key held by another account on this + // instance (a portable file from elsewhere) is re-minted, and the + // medications below are mapped onto the new key. + await tx.medicationCategoryLabel.deleteMany({ + where: { userId: ownerId }, + }); + const categoryKeyRemap = new Map(); + for (const c of payload.customMedicationCategories) { + const taken = await tx.medicationCategoryLabel.findUnique({ + where: { key: c.key }, + select: { id: true }, + }); + const key = taken ? mintCustomMedicationCategoryKey() : c.key; + if (key !== c.key) categoryKeyRemap.set(c.key, key); + await tx.medicationCategoryLabel.create({ + data: { + userId: ownerId, + key, + labelEncrypted: encryptCategoryLabel(c.label), + sortOrder: c.sortOrder, + isActive: c.isActive, + ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + }, + }); + } + let medicationIndex = 0; reportSection("medications"); const restoreStartedAt = new Date(); @@ -1262,7 +1294,11 @@ export async function restoreBackup( // medication id, so it is written after the row exists and inside the // same transaction. OTHER is what a missing row already reads as. if (m.category && m.category !== "OTHER") { - await setMedicationCategory(created.id, m.category, tx); + await setMedicationCategory( + created.id, + categoryKeyRemap.get(m.category) ?? m.category, + tx, + ); } // ── The archived schedule eras, in two passes ──────────────── diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts index eae946c0a..72794edcf 100644 --- a/src/lib/mcp/__tests__/resources.test.ts +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -10,6 +10,9 @@ vi.mock("@/lib/db", () => ({ }, })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/medication-category", () => ({ + resolveMedicationCategories: vi.fn(), +})); vi.mock("@/lib/doctor-report-data", () => ({ collectDoctorReportData: vi.fn(), })); @@ -27,6 +30,7 @@ import { prisma } from "@/lib/db"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; import { metricStatusDiscoveryRows } from "@/lib/mcp/rich-reads"; +import { resolveMedicationCategories } from "@/lib/medication-category"; import type { McpAuthContext } from "../auth"; const CTX: McpAuthContext = { @@ -104,8 +108,15 @@ describe("healthlog://profile", () => { describe("healthlog://medications", () => { it("lists medications + schedules scoped to the session user", async () => { + vi.mocked(resolveMedicationCategories).mockResolvedValue({ + "med-a": { + category: "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", + categoryLabel: "Travel kit", + }, + }); vi.mocked(prisma.medication.findMany).mockResolvedValue([ { + id: "med-a", name: "Med A", dose: "10mg", treatmentClass: "GENERIC", @@ -139,6 +150,14 @@ describe("healthlog://medications", () => { expect(result.present).toBe(true); expect(result.count).toBe(1); expect(Array.isArray(result.medications)).toBe(true); + // v1.40 (#1041) — the custom category rides with its label, fenced as + // the person's own text. + expect( + (result.medications as Array>)[0], + ).toMatchObject({ + category: "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", + categoryLabel: "<<>>Travel kit<<>>", + }); }); it("returns { present: false } when no medications are tracked", async () => { diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index f0bf9d68c..357894bab 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -26,6 +26,8 @@ * external-assistant boundary. */ import { prisma } from "@/lib/db"; +import { fenceUserText } from "@/lib/ai/coach/data-fence"; +import { resolveMedicationCategories } from "@/lib/medication-category"; import { annotate } from "@/lib/logging/context"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; @@ -130,6 +132,11 @@ function ageYears(dateOfBirth: Date | null): number | null { */ const MODULE_DISABLED = { present: false, reason: "module_disabled" } as const; +/** A person-written label, fenced as user text; null stays null. */ +function fenceOptional(text: string | null | undefined): string | null { + return text ? fenceUserText(text) : null; +} + function firstVar(value: string | string[] | undefined): string { if (Array.isArray(value)) return value[0] ?? ""; return value ?? ""; @@ -193,6 +200,9 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ meta: { resource: "medications", present: medications.length > 0 }, }); if (medications.length === 0) return { present: false, count: 0 }; + const categories = await resolveMedicationCategories( + medications.map((med) => med.id), + ); return { present: true, count: medications.length, @@ -200,6 +210,11 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ name: med.name, dose: med.dose, treatmentClass: med.treatmentClass, + // v1.40 (#1041) — the clinical category: a built-in value or + // `custom:`, with the person's own label (their text, fenced) + // for a custom one. + category: categories[med.id]?.category ?? "OTHER", + categoryLabel: fenceOptional(categories[med.id]?.categoryLabel), asNeeded: med.asNeeded, // v1.39.1 (#1033) — false: kept as a record, nothing is due and // no adherence applies; the schedules below are information. diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index ad4cd1c8b..f6240db5a 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -1,11 +1,31 @@ +import { randomUUID } from "node:crypto"; + import type { Prisma } from "@/generated/prisma/client"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { prisma } from "@/lib/db"; -import { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication"; +import { annotate } from "@/lib/logging/context"; +import { + CUSTOM_MEDICATION_CATEGORY_PREFIX, + MEDICATION_CATEGORY_VALUES, + isCustomMedicationCategoryKey, +} from "@/lib/validations/medication"; const MEDICATION_CATEGORIES = MEDICATION_CATEGORY_VALUES; export type MedicationCategory = (typeof MEDICATION_CATEGORIES)[number]; +/** + * What a medication's `category` resolves to: a built-in value, or the + * `custom:` key of one of its owner's own categories. + */ +export type MedicationCategoryKey = MedicationCategory | `custom:${string}`; + +export interface ResolvedMedicationCategory { + category: MedicationCategoryKey; + /** The decrypted label of a custom category; null for a built-in one. */ + categoryLabel: string | null; +} + const DEFAULT_CATEGORY: MedicationCategory = "OTHER"; /** @@ -16,43 +36,139 @@ const DEFAULT_CATEGORY: MedicationCategory = "OTHER"; */ type CategoryClient = Pick< Prisma.TransactionClient, - "medicationCategoryAssignment" + "medicationCategoryAssignment" | "medicationCategoryLabel" >; -function normalizeCategory(input: unknown): MedicationCategory { - if (typeof input !== "string") return DEFAULT_CATEGORY; - return MEDICATION_CATEGORIES.includes(input as MedicationCategory) - ? (input as MedicationCategory) - : DEFAULT_CATEGORY; +function isBuiltIn(input: string): input is MedicationCategory { + return MEDICATION_CATEGORIES.includes(input as MedicationCategory); } -export async function getMedicationCategories( +/** + * Each medication's category, resolved against its owner's own labels. A + * custom key resolves only to a label owned by the medication's owner (active + * or hidden: hiding affects the picker, not what a medication shows). A key + * with no such label — a restore that carried the medication but not the + * label — reads as OTHER and is counted on the wide event, never a 500. + */ +export async function resolveMedicationCategories( medicationIds: string[], client: CategoryClient = prisma, -): Promise> { +): Promise> { if (medicationIds.length === 0) return {}; const rows = await client.medicationCategoryAssignment.findMany({ where: { medicationId: { in: medicationIds } }, - select: { medicationId: true, category: true }, + select: { + medicationId: true, + category: true, + medication: { select: { userId: true } }, + }, }); - const map: Record = {}; + const customKeys = [ + ...new Set( + rows + .map((row) => row.category) + .filter((c) => isCustomMedicationCategoryKey(c)), + ), + ]; + const labels = + customKeys.length === 0 + ? [] + : await client.medicationCategoryLabel.findMany({ + where: { key: { in: customKeys } }, + select: { key: true, userId: true, labelEncrypted: true }, + }); + const labelByKey = new Map(labels.map((l) => [l.key, l])); + + const map: Record = {}; for (const id of medicationIds) { - map[id] = DEFAULT_CATEGORY; + map[id] = { category: DEFAULT_CATEGORY, categoryLabel: null }; } + let dangling = 0; for (const row of rows) { - map[row.medicationId] = normalizeCategory(row.category); + if (isBuiltIn(row.category)) { + map[row.medicationId] = { category: row.category, categoryLabel: null }; + continue; + } + const label = labelByKey.get(row.category); + if ( + isCustomMedicationCategoryKey(row.category) && + label && + label.userId === row.medication.userId + ) { + map[row.medicationId] = { + category: row.category as MedicationCategoryKey, + categoryLabel: decryptFromBytes(label.labelEncrypted), + }; + continue; + } + if (isCustomMedicationCategoryKey(row.category)) dangling += 1; + } + if (dangling > 0) { + annotate({ meta: { "medication.category.dangling": dangling } }); } return map; } +/** Each medication's resolved category key (built-in or custom), no labels. */ +export async function getMedicationCategories( + medicationIds: string[], + client: CategoryClient = prisma, +): Promise> { + const resolved = await resolveMedicationCategories(medicationIds, client); + return Object.fromEntries( + Object.entries(resolved).map(([id, r]) => [id, r.category]), + ); +} + +/** + * Whether `category` may be written onto a medication of `userId`: any + * built-in value, or a custom key whose label belongs to that account. + */ +export async function isAssignableCategory( + userId: string, + category: string, + client: CategoryClient = prisma, +): Promise { + if (isBuiltIn(category)) return true; + if (!isCustomMedicationCategoryKey(category)) return false; + const label = await client.medicationCategoryLabel.findFirst({ + where: { key: category, userId }, + select: { key: true }, + }); + return label !== null; +} + +/** + * Write a medication's category. A built-in value is stored as given; a + * custom key only when it belongs to the medication's owner, otherwise the + * write falls back to OTHER (the routes refuse a foreign key with a 422 + * before they get here; this is the floor for every other caller). + */ export async function setMedicationCategory( medicationId: string, category: unknown, - client: CategoryClient = prisma, -) { - const normalized = normalizeCategory(category); + client: CategoryClient & + Pick = prisma, +): Promise { + let normalized: MedicationCategoryKey = DEFAULT_CATEGORY; + if (typeof category === "string") { + if (isBuiltIn(category)) { + normalized = category; + } else if (isCustomMedicationCategoryKey(category)) { + const owner = await client.medication.findUnique({ + where: { id: medicationId }, + select: { userId: true }, + }); + if ( + owner && + (await isAssignableCategory(owner.userId, category, client)) + ) { + normalized = category as MedicationCategoryKey; + } + } + } await client.medicationCategoryAssignment.upsert({ where: { medicationId }, @@ -71,3 +187,123 @@ export async function deleteMedicationCategory( where: { medicationId }, }); } + +// ── The person's own categories ───────────────────────────────────────────── + +export interface MedicationCategoryLabelDto { + key: string; + label: string; + sortOrder: number; + isActive: boolean; + /** How many of the owner's medications are filed under it. */ + medicationCount: number; +} + +/** Mint the key of a new custom category. */ +export function mintCustomMedicationCategoryKey(): string { + return `${CUSTOM_MEDICATION_CATEGORY_PREFIX}${randomUUID()}`; +} + +export function encryptCategoryLabel(label: string): Uint8Array { + return encryptToBytes(label); +} + +export function decryptCategoryLabel(bytes: Uint8Array): string { + return decryptFromBytes(bytes); +} + +/** Every custom category of `userId`, hidden ones included, in picker order. */ +export async function listMedicationCategoryLabels( + userId: string, + client: CategoryClient = prisma, +): Promise { + const rows = await client.medicationCategoryLabel.findMany({ + where: { userId }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + select: { + key: true, + labelEncrypted: true, + sortOrder: true, + isActive: true, + }, + }); + if (rows.length === 0) return []; + const counts = await client.medicationCategoryAssignment.groupBy({ + by: ["category"], + where: { + category: { in: rows.map((r) => r.key) }, + medication: { userId }, + }, + _count: { _all: true }, + }); + const countByKey = new Map(counts.map((c) => [c.category, c._count._all])); + return rows.map((r) => ({ + key: r.key, + label: decryptCategoryLabel(r.labelEncrypted), + sortOrder: r.sortOrder, + isActive: r.isActive, + medicationCount: countByKey.get(r.key) ?? 0, + })); +} + +/** + * Every custom category of `userId` as the backup carries it: the decrypted + * label (re-encrypted under the restoring instance's key) and its creation + * instant, hidden ones included. + */ +export async function readMedicationCategoryLabelsForBackup( + userId: string, + client: CategoryClient = prisma, +): Promise< + Array<{ + key: string; + label: string; + sortOrder: number; + isActive: boolean; + createdAt: string; + }> +> { + const rows = await client.medicationCategoryLabel.findMany({ + where: { userId }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + select: { + key: true, + labelEncrypted: true, + sortOrder: true, + isActive: true, + createdAt: true, + }, + }); + return rows.map((r) => ({ + key: r.key, + label: decryptCategoryLabel(r.labelEncrypted), + sortOrder: r.sortOrder, + isActive: r.isActive, + createdAt: r.createdAt.toISOString(), + })); +} + +/** + * Delete one of `userId`'s custom categories. Its medications move to OTHER + * in the same transaction, so no medication is ever left pointing at a key + * that no longer exists. Returns how many moved, or null when the key is not + * one of the caller's. + */ +export async function deleteMedicationCategoryLabel( + userId: string, + key: string, +): Promise<{ movedCount: number } | null> { + return prisma.$transaction(async (tx) => { + const owned = await tx.medicationCategoryLabel.findFirst({ + where: { key, userId }, + select: { id: true }, + }); + if (!owned) return null; + const moved = await tx.medicationCategoryAssignment.updateMany({ + where: { category: key, medication: { userId } }, + data: { category: DEFAULT_CATEGORY }, + }); + await tx.medicationCategoryLabel.delete({ where: { id: owned.id } }); + return { movedCount: moved.count }; + }); +} diff --git a/src/lib/medications/__tests__/category-filter.test.ts b/src/lib/medications/__tests__/category-filter.test.ts new file mode 100644 index 000000000..2dc819a9a --- /dev/null +++ b/src/lib/medications/__tests__/category-filter.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + ALL_MEDICATION_CATEGORIES, + effectiveCategoryFilter, + filterMedicationsByCategory, + medicationCategoryOptions, +} from "@/lib/medications/category-filter"; +import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; + +const KEY = "custom:11111111-1111-4111-8111-111111111111"; +const t = (key: string) => `t:${key}`; + +const MEDS = [ + { id: "a", active: true, category: "THYROID", categoryLabel: null }, + { id: "b", active: true, category: KEY, categoryLabel: "Travel kit" }, + { id: "c", active: false, category: KEY, categoryLabel: "Travel kit" }, + { id: "d", active: false, category: "THYROID", categoryLabel: null }, +]; + +describe("medication category filter (v1.40, #1041)", () => { + it("offers each category in use once, labelled like the card badge", () => { + expect(medicationCategoryOptions(MEDS, t)).toEqual([ + { value: "THYROID", label: "t:medications.categoryThyroid" }, + { value: KEY, label: "Travel kit" }, + ]); + }); + + it("narrows the active and the inactive block alike", () => { + const active = MEDS.filter((m) => m.active); + const inactive = MEDS.filter((m) => !m.active); + expect(filterMedicationsByCategory(active, KEY).map((m) => m.id)).toEqual([ + "b", + ]); + expect(filterMedicationsByCategory(inactive, KEY).map((m) => m.id)).toEqual( + ["c"], + ); + expect( + filterMedicationsByCategory(active, ALL_MEDICATION_CATEGORIES).map( + (m) => m.id, + ), + ).toEqual(["a", "b"]); + }); + + it("falls back to All when the stored pick is no longer in use", () => { + const options = medicationCategoryOptions(MEDS, t); + expect(effectiveCategoryFilter(KEY, options)).toBe(KEY); + expect( + effectiveCategoryFilter( + "custom:99999999-9999-4999-8999-999999999999", + options, + ), + ).toBe(ALL_MEDICATION_CATEGORIES); + }); + + it("labels a custom category by its own label and an unresolved key as Other", () => { + expect(getMedicationCategoryLabel(KEY, t, "Travel kit")).toBe("Travel kit"); + expect(getMedicationCategoryLabel(KEY, t, null)).toBe( + "t:medications.categoryOther", + ); + expect(getMedicationCategoryLabel("VITAMIN", t)).toBe( + "t:medications.categoryVitamin", + ); + }); +}); diff --git a/src/lib/medications/category-filter.ts b/src/lib/medications/category-filter.ts new file mode 100644 index 000000000..67d1d08b8 --- /dev/null +++ b/src/lib/medications/category-filter.ts @@ -0,0 +1,66 @@ +/** + * v1.40 (#1041) — the category filter on the medications list. Pure, so the + * page and its tests share one definition of what the filter offers and what + * it keeps. + */ +import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; + +type Translator = ( + key: string, + params?: Record, +) => string; + +/** The "no filter" sentinel, the value `FilterBarSelect` resets to. */ +export const ALL_MEDICATION_CATEGORIES = "ALL"; + +interface CategorisedMedication { + category: string; + categoryLabel?: string | null; +} + +/** + * The categories the given medications use, each once, labelled the way the + * card badge labels it, sorted by label. The filter offers only these, so a + * pick can never empty the list. + */ +export function medicationCategoryOptions( + medications: readonly CategorisedMedication[], + t: Translator, +): { value: string; label: string }[] { + const byValue = new Map(); + for (const m of medications) { + if (!byValue.has(m.category)) { + byValue.set( + m.category, + getMedicationCategoryLabel(m.category, t, m.categoryLabel), + ); + } + } + return [...byValue.entries()] + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +/** + * The filter value that actually applies: a stored pick whose category no + * medication uses any more (deleted, or every medication moved) falls back to + * All rather than showing an empty list. + */ +export function effectiveCategoryFilter( + stored: string, + options: readonly { value: string }[], +): string { + return options.some((option) => option.value === stored) + ? stored + : ALL_MEDICATION_CATEGORIES; +} + +/** Keep the medications the filter admits, in their order. */ +export function filterMedicationsByCategory( + medications: readonly T[], + filter: string, +): T[] { + return filter === ALL_MEDICATION_CATEGORIES + ? [...medications] + : medications.filter((m) => m.category === filter); +} diff --git a/src/lib/medications/category-label.ts b/src/lib/medications/category-label.ts index 087a2f188..0dbabc118 100644 --- a/src/lib/medications/category-label.ts +++ b/src/lib/medications/category-label.ts @@ -38,10 +38,23 @@ const MEDICATION_CATEGORY_KEYS: Record = { OTHER: "medications.categoryOther", }; +/** + * v1.40 (#1041) — a category the person named themselves arrives as + * `custom:` with its decrypted label beside it (`categoryLabel` on the + * medication wire); that label wins. A custom key without a label (the server + * resolved it to nothing) reads as Other, like any unknown value. + */ export function getMedicationCategoryLabel( category: string, t: Translator, + customLabel?: string | null, ): string { + if (customLabel) return customLabel; const key = MEDICATION_CATEGORY_KEYS[category] ?? "medications.categoryOther"; return t(key); } + +/** The built-in category values, in the order the filter row lists them. */ +export const BUILT_IN_MEDICATION_CATEGORIES = Object.keys( + MEDICATION_CATEGORY_KEYS, +); diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 6af8d17d0..e40eeafb5 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -14,7 +14,10 @@ */ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { + resolveMedicationCategories, + type ResolvedMedicationCategory, +} from "@/lib/medication-category"; import { effectiveUnitsPerDose, estimateUnitsRunwayDays, @@ -200,9 +203,11 @@ export async function buildMedicationsList( } const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId)); - let categoryMap: Record = {}; + let categoryMap: Record = {}; try { - categoryMap = await getMedicationCategories(medications.map((m) => m.id)); + categoryMap = await resolveMedicationCategories( + medications.map((m) => m.id), + ); } catch { getEvent()?.addWarning("Medication categories could not be loaded"); } @@ -300,7 +305,10 @@ export async function buildMedicationsList( // v1.39.1 (#1033) — `schedules: []` plus `recordedSchedules` when // intake tracking is off (see `scheduleWireFields`). ...scheduleWireFields(m.trackIntake, schedulesDto), - category: categoryMap[m.id] ?? "OTHER", + category: categoryMap[m.id]?.category ?? "OTHER", + // v1.40 (#1041) — the decrypted label of a custom category; null for + // a built-in one, which every client localises from `category`. + categoryLabel: categoryMap[m.id]?.categoryLabel ?? null, // 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 diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 9f4d83bf7..4b1cfd108 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -249,6 +249,9 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], medication: ["medication.intake.import.invalid_format"], medications: [ + "medications.category.invalid", + "medications.category.limitReached", + "medications.category.unknown", "medications.intake.bulk.apple_health_not_mirrored", "medications.intake.bulk.invalid", "medications.intake.bulk.too_large", diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index bdfc6ae33..c4b5d2ba1 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -20,6 +20,8 @@ import { glp1PostBodySchema, bulkDeleteIntakeEventsSchema, updateIntakeEventSchema, + createMedicationCategoryLabelSchema, + updateMedicationCategoryLabelSchema, } from "@/lib/validations/medication"; import { scheduleRevisionCreateSchema, @@ -50,12 +52,31 @@ const setMedicationEfficacyTargetRequest = efficacyTargetOverrideSchema.meta({ "Set or clear the user's explicit efficacy-target override for a medication. `clear:true` removes the override so the resolver reverts to the derived (ATC class prefix → name inference) target; otherwise pin exactly ONE of `measurementType` (a metric series) / `biomarkerId` (a lab analyte). `userId` is never a field — ownership is narrowed through the medication (and the biomarker for a lab target).", }); +const createMedicationCategoryRequest = + createMedicationCategoryLabelSchema.meta({ + id: "CreateMedicationCategoryRequest", + description: + "v1.40 — name a new medication category. The label is trimmed, 1 to 40 characters, and encrypted at rest.", + }); + +const updateMedicationCategoryRequest = + updateMedicationCategoryLabelSchema.meta({ + id: "UpdateMedicationCategoryRequest", + description: + "v1.40 — rename, reorder or hide a custom medication category. At least one field. Hiding (`isActive: false`) removes it from the picker; medications filed under it keep it.", + }); + +const categoryKeyPath = z.object({ + key: z.string().describe("`custom:`, the category's key."), +}); + const medicationEfficacyResponse = medicationEfficacyResponseSchema.meta({ id: "MedicationEfficacyResponse", description: "Server-authoritative, strictly-descriptive efficacy view relating a medication to the outcome metric(s)/lab(s) its class is prescribed to move, around its start. Carries the resolved target(s) with their series, the start/dose-change/pause markers, a before/after-start comparison (honest `{present:false}` below the per-side data floor), an adherence lane (cadence-aware per-day rate, never recomputed), an optional conservative level-shift note, and the retarget options. There is NO verdict / score / assessment field by construction — the client renders numbers and neutral connective phrasing only, never a causal or dose-advice claim.", }); import { + medicationCategoryLabelResource, medicationListEntry, medicationDetailEntry, medicationInventoryItemResource, @@ -923,7 +944,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`. 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.", + "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. v1.40 — `category` may be the key of one of the caller's own categories; any other `custom:` key is 422 `medications.category.unknown`.", requestBody: { required: true, content: { "application/json": { schema: createMedicationSchema } }, @@ -946,6 +967,125 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/categories": { + get: { + tags: ["Medications"], + summary: "List the record's own medication categories", + description: + "v1.40 (#1041) — every custom category of the record, hidden ones included, in picker order (`sortOrder`, then creation), each with the number of medications filed under it. The built-in categories are not listed; they are the fixed values of `MedicationCategory`. Delegable at READ level over the `medications` section.", + responses: { + ...recordRefusal(), + "200": { + description: "The custom categories.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + categories: z.array(medicationCategoryLabelResource), + }), + "ListMedicationCategoriesResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + parameters: [idempotencyKeyParameter], + tags: ["Medications"], + summary: "Create a custom medication category", + description: + "v1.40 (#1041) — mints a `custom:` key and stores the label encrypted. At most 20 per account, hidden ones included: one more is 422 `medications.category.limitReached`; an invalid body is 422 `medications.category.invalid`. Rate-limited 30/min keyed on the actor. Delegable at MANAGE level over the `medications` section. The label never reaches an audit row.", + requestBody: { + required: true, + content: { + "application/json": { schema: createMedicationCategoryRequest }, + }, + }, + responses: { + ...idempotentWrite(), + ...recordRefusal(), + "201": { + description: "The new category.", + content: { + "application/json": { + schema: dataEnvelope( + medicationCategoryLabelResource, + "CreateMedicationCategoryResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/categories/{key}": { + patch: { + tags: ["Medications"], + summary: "Rename, reorder or hide a custom medication category", + description: + "v1.40 (#1041) — field-by-field update of one of the record's own categories. Another account's key, or a built-in value, is 404. Owner-only: not delegable, refused under a record switch. Rate-limited 30/min.", + requestParams: { path: categoryKeyPath }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateMedicationCategoryRequest }, + }, + }, + responses: { + "200": { + description: "The updated category.", + content: { + "application/json": { + schema: dataEnvelope( + medicationCategoryLabelResource, + "UpdateMedicationCategoryResponse", + ), + }, + }, + }, + "404": { + description: "Not one of the record's own categories.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Delete a custom medication category", + description: + "v1.40 (#1041) — deletes the category and, in the same transaction, moves every medication filed under it to OTHER; `movedCount` says how many moved. No medication is left pointing at a key that no longer exists. Another account's key, or a built-in value, is 404. Owner-only: not delegable, refused under a record switch.", + requestParams: { path: categoryKeyPath }, + responses: { + "200": { + description: "Deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + key: z.string(), + movedCount: z + .number() + .int() + .nonnegative() + .describe("Medications moved to OTHER."), + }), + "DeleteMedicationCategoryResponse", + ), + }, + }, + }, + "404": { + description: "Not one of the record's own categories.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/medications/layout": { get: { tags: ["Medications"], @@ -1055,7 +1195,7 @@ export const medicationPaths: NonNullable = { tags: ["Medications"], summary: "Replace a medication (partial fields)", description: - "Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`.", + "Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`. v1.40 — an absent `category` leaves the stored one untouched (send it only when the person changed it); a `custom:` key that is not the caller's is 422 `medications.category.unknown`.", requestParams: { path: z.object({ id: z.string() }), }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 957de5197..905c53cf1 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -33,12 +33,52 @@ import { MEDICATION_IMPORT_SKIP_REASONS } from "@/lib/jobs/medication-intake-imp // schema description AND enforced by the route + a DB CHECK constraint // so iOS code-gen surfaces the mutual exclusion. -export const medicationCategoryEnum = z.enum(MEDICATION_CATEGORY_VALUES).meta({ +// v1.40 (#1041) — widened from the built-in enum to a string: a category is +// a built-in value or the `custom:` key of one of the caller's own +// categories. The component id stays `MedicationCategory` so a generated +// client keeps its type name; a client decodes unknown values tolerantly and +// shows `categoryLabel` when present. +export const medicationCategoryEnum = z.string().meta({ id: "MedicationCategory", - description: - "Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.", + description: `Clinical category. One of the built-in values (${MEDICATION_CATEGORY_VALUES.join(", ")}) or, since v1.40, \`custom:\`, the key of one of the caller's own categories (see \`GET /api/medications/categories\`), whose label rides beside it as \`categoryLabel\`. A client must decode an unknown value tolerantly and must not write \`category\` back unless the person changed it. Orthogonal to \`MedicationTreatmentClass\`.`, }); +const medicationCategoryLabelField = z + .string() + .nullable() + .describe( + "v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in category, which a client localises from `category`. Read-only.", + ); + +export const medicationCategoryLabelResource = z + .object({ + key: z + .string() + .describe( + "`custom:`, the value a medication's `category` carries when it is filed under this category.", + ), + label: z.string().describe("The person's own label, 1 to 40 characters."), + sortOrder: z + .number() + .int() + .describe("Position in the picker and the filter row, ascending."), + isActive: z + .boolean() + .describe( + "false = hidden from the picker. Medications filed under a hidden category keep it.", + ), + medicationCount: z + .number() + .int() + .nonnegative() + .describe("How many of the record's medications are filed under it."), + }) + .meta({ + id: "MedicationCategoryLabel", + description: + "v1.40 (#1041) — a medication category the person named themselves, shown next to the built-in ones. At most 20 per account (hidden ones count).", + }); + export const medicationTreatmentClassEnum = z .enum(MEDICATION_TREATMENT_CLASS_VALUES) .meta({ @@ -250,6 +290,7 @@ export const medicationResource = z export const medicationListEntry = medicationResource .extend({ category: medicationCategoryEnum, + categoryLabel: medicationCategoryLabelField, // 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 @@ -315,6 +356,7 @@ export const medicationListEntry = medicationResource export const medicationDetailEntry = medicationResource .extend({ category: medicationCategoryEnum, + categoryLabel: medicationCategoryLabelField, courseStatus: z .enum(["UPCOMING", "CURRENT", "ENDED"]) .describe( diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index b7f5b2f1a..9b8ff41aa 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -52,6 +52,13 @@ export const medicationKeys = { * layout PUT's response. */ medicationListLayout: () => ["medication-list-layout"] as const, + /** + * v1.40 (#1041) — the record's own medication categories + * (`GET /api/medications/categories`). Under the `["medications"]` prefix: + * a delete moves medications to Other, so the list must refetch with it, + * and a medication write changes the per-category counts. + */ + medicationCategories: () => ["medications", "categories"] as const, medicationCadence: (medicationId: string) => ["medications", medicationId, "cadence"] as const, medicationGlp1Details: (medicationId: string) => diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index b9e7a128d..4876b194b 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -225,6 +225,10 @@ export function recordActivityVerbLine( case "medication.update": return t("recordSharing.activityVerb.medicationUpdate", { name }); + case "medication.category.custom.create": + return t("recordSharing.activityVerb.medicationCategoryCreate", { + name, + }); case "medication.delete": return t("recordSharing.activityVerb.medicationDelete", { name }); case "medication.glp1.update": diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 0613a290d..5bb94f86e 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -333,6 +333,22 @@ const medicationEfficacyTargetSchema = z }) .passthrough(); +/** + * v1.40 (#1041) — a medication category the account named itself. Carried + * with its decrypted label (the label is re-encrypted on restore under the + * restoring instance's key) and restored BEFORE the medications, whose + * `category` names it by `key`. + */ +const customMedicationCategorySchema = z + .object({ + key: z.string().regex(/^custom:[0-9a-f-]{36}$/), + label: z.string().trim().min(1).max(40), + sortOrder: z.number().int().min(0).default(0), + isActive: z.boolean().default(true), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -361,7 +377,8 @@ const medicationSchema = z // v1.39.4 — the clinical category. Absent in older files. A string, not // the enum: a file from a newer server may name a category this one does // not know, and the restore reads that as OTHER rather than refusing the - // whole file. + // whole file. Since v1.40 it may be `custom:`, the key of an entry + // in `customMedicationCategories`. category: z.string().max(64).optional(), deliveryForm: z.enum(MedicationDeliveryForm).optional(), trackInjectionSites: z.boolean().optional(), @@ -1834,6 +1851,10 @@ export const backupPayloadSchema = z accountSettings: accountSettingsBackupSchema.nullable().default(null), measurements: z.array(measurementSchema).default([]), medications: z.array(medicationSchema).default([]), + // v1.40 — absent in older files, which carry no custom category either. + customMedicationCategories: z + .array(customMedicationCategorySchema) + .default([]), intakeEvents: z.array(intakeEventSchema).default([]), moodEntries: z.array(moodEntrySchema).default([]), // v1.15.0 — cycle-tracking tables. Default to empty arrays / null so a diff --git a/src/lib/validations/medication/base.ts b/src/lib/validations/medication/base.ts index fdd1ff527..6590fad67 100644 --- a/src/lib/validations/medication/base.ts +++ b/src/lib/validations/medication/base.ts @@ -63,8 +63,8 @@ export const rxNormCodeField = z "Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. NULL clears it; absent leaves it untouched.", ); /** - * Clinical-category values stored in the `medication_categories` side- - * table (TEXT column). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the + * Clinical-category values stored in the `medication_categories` table + * (TEXT column, the `MedicationCategoryAssignment` model). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the * wizard's Step 2 taxonomy can write a first-class bucket for those * two rows instead of collapsing them into `OTHER`. The column is a * plain TEXT field — no Prisma enum exists to migrate; the Zod values @@ -91,6 +91,63 @@ export const MEDICATION_CATEGORY_VALUES = [ export type MedicationCategoryValue = (typeof MEDICATION_CATEGORY_VALUES)[number]; +/** + * v1.40 (#1041) — a category the person named themselves is addressed by the + * key of its `MedicationCategoryLabel` row, `custom:`. The `category` + * field on the medication wire is a built-in value or such a key. + */ +export const CUSTOM_MEDICATION_CATEGORY_PREFIX = "custom:"; + +/** Per-account ceiling on custom medication categories (hidden ones count). */ +export const MAX_CUSTOM_MEDICATION_CATEGORIES = 20; + +const CUSTOM_MEDICATION_CATEGORY_KEY = /^custom:[0-9a-f-]{36}$/; + +/** Is `value` a custom-category key rather than a built-in value? */ +export function isCustomMedicationCategoryKey(value: string): boolean { + return value.startsWith(CUSTOM_MEDICATION_CATEGORY_PREFIX); +} + +/** + * The `category` a create or update body may carry: a built-in value or a + * well-formed custom key. Ownership of a custom key is checked by the route + * (422 `medications.category.unknown`), not here. + */ +export const medicationCategoryInputSchema = z + .union([ + z.enum(MEDICATION_CATEGORY_VALUES), + z.string().regex(CUSTOM_MEDICATION_CATEGORY_KEY, "Unknown category"), + ]) + .describe( + "A built-in clinical category (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, DIGESTIVE, THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or `custom:`, the key of one of the caller's own categories.", + ); + +const customCategoryLabelSchema = z + .string() + .trim() + .min(1, "Label must not be empty") + .max(40, "Label must be at most 40 characters"); + +/** `POST /api/medications/categories` body. */ +export const createMedicationCategoryLabelSchema = z.object({ + label: customCategoryLabelSchema, +}); + +/** `PATCH /api/medications/categories/{key}` body: at least one field. */ +export const updateMedicationCategoryLabelSchema = z + .object({ + label: customCategoryLabelSchema.optional(), + sortOrder: z.number().int().min(0).max(1000).optional(), + isActive: z.boolean().optional(), + }) + .refine( + (v) => + v.label !== undefined || + v.sortOrder !== undefined || + v.isActive !== undefined, + "At least one field is required", + ); + /** * v1.4.25 W4d — Prisma-level treatment class. Orthogonal to * `MEDICATION_CATEGORY_VALUES` (the clinical taxonomy that lives in the diff --git a/src/lib/validations/medication/create-update.ts b/src/lib/validations/medication/create-update.ts index b774984ef..7337b34a4 100644 --- a/src/lib/validations/medication/create-update.ts +++ b/src/lib/validations/medication/create-update.ts @@ -1,7 +1,7 @@ import { z } from "zod/v4"; import { - MEDICATION_CATEGORY_VALUES, + medicationCategoryInputSchema, MEDICATION_DELIVERY_FORM_VALUES, MEDICATION_TREATMENT_CLASS_VALUES, atcCodeField, @@ -99,7 +99,7 @@ export const createMedicationSchema = z .object({ name: z.string().min(1).max(100), dose: z.string().min(1).max(50), - category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), + category: medicationCategoryInputSchema.optional(), /** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */ treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), /** v1.4.25 W4d — doses per pen/vial for inventory tracking. @@ -267,7 +267,7 @@ export const updateMedicationSchema = z .object({ name: z.string().min(1).max(100).optional(), dose: z.string().min(1).max(50).optional(), - category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), + category: medicationCategoryInputSchema.optional(), treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(), /** v1.16.10 — inventory units one dose consumes. Default 1. diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index e90d4748e..0f1488ed3 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -681,6 +681,14 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "create", ), + handler( + "app/api/medications/categories/route.ts", + "/api/medications/categories", + "POST", + "medications", + "manage", + "create", + ), handler( "app/api/nutrients/water/route.ts", "/api/nutrients/water", diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6d082054d..ec6b9db60 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -66,7 +66,7 @@ import { } from "@/lib/withings/ecg-waveform-codec"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { - getMedicationCategories, + resolveMedicationCategories, setMedicationCategory, } from "@/lib/medication-category"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; @@ -188,6 +188,7 @@ const COACH_RESULTS_JSON = JSON.stringify([ chart: { kind: "line", x: "period", series: ["sys"] }, }, ]); +const ROUND_TRIP_CATEGORY_KEY = "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10"; const DOSE_CHANGE_NOTE = "titration note, encrypted at rest"; const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)"; const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose"; @@ -223,6 +224,8 @@ const COUNT_BACK: Record< // No own `userId` column — reached through the drug, like the schedules. MedicationCategoryAssignment: (p, userId) => p.medicationCategoryAssignment.count({ where: { medication: { userId } } }), + MedicationCategoryLabel: (p, userId) => + p.medicationCategoryLabel.count({ where: { userId } }), MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }), MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }), MoodEntryTagLink: (p, userId) => @@ -427,9 +430,18 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, }); // Not OTHER: a missing row already reads as OTHER, so only a chosen value - // proves the category came back. + // proves the category came back. A category the person named, so the + // assignment can only resolve if the label came back before it. + await prisma.medicationCategoryLabel.create({ + data: { + userId: OWNER_ID, + key: ROUND_TRIP_CATEGORY_KEY, + labelEncrypted: encryptToBytes("Heart and circulation"), + sortOrder: 2, + }, + }); await prisma.medicationCategoryAssignment.create({ - data: { medicationId: medication.id, category: "THYROID" }, + data: { medicationId: medication.id, category: ROUND_TRIP_CATEGORY_KEY }, }); // The one row in this fixture that is also checked field by field after the // restore — see the assertion at the end of the test for why. @@ -2809,8 +2821,22 @@ describe("every model the plan claims two-ended survives a real restore", () => await prisma.medication.create({ data: { userId: OWNER_ID, name: "Unfiled", dose: "1 tab" }, }); + const travel = await prisma.medication.create({ + data: { userId: OWNER_ID, name: "Tamiflu", dose: "75 mg" }, + }); await setMedicationCategory(thyroid.id, "THYROID"); await setMedicationCategory(mood.id, "MENTAL_HEALTH"); + // v1.40 — a category the person named. Hidden, because a hidden + // category still files the medication and must travel with it. + await prisma.medicationCategoryLabel.create({ + data: { + userId: OWNER_ID, + key: ROUND_TRIP_CATEGORY_KEY, + labelEncrypted: encryptToBytes("Travel kit"), + isActive: false, + }, + }); + await setMedicationCategory(travel.id, ROUND_TRIP_CATEGORY_KEY); const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { purpose, @@ -2824,7 +2850,17 @@ describe("every model the plan claims two-ended survives a real restore", () => Levothyroxine: "THYROID", Sertraline: "MENTAL_HEALTH", Unfiled: "OTHER", + Tamiflu: ROUND_TRIP_CATEGORY_KEY, }); + expect(payload.customMedicationCategories).toEqual([ + { + key: ROUND_TRIP_CATEGORY_KEY, + label: "Travel kit", + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + }, + ]); await prisma.user.delete({ where: { id: OWNER_ID } }); await createOwner(prisma); @@ -2849,16 +2885,24 @@ describe("every model the plan claims two-ended survives a real restore", () => where: { userId: OWNER_ID }, select: { id: true, name: true }, }); - const categories = await getMedicationCategories( + const categories = await resolveMedicationCategories( restored.map((m) => m.id), ); expect( Object.fromEntries(restored.map((m) => [m.name, categories[m.id]])), ).toEqual({ - Levothyroxine: "THYROID", - Sertraline: "MENTAL_HEALTH", - Unfiled: "OTHER", + Levothyroxine: { category: "THYROID", categoryLabel: null }, + Sertraline: { category: "MENTAL_HEALTH", categoryLabel: null }, + Unfiled: { category: "OTHER", categoryLabel: null }, + Tamiflu: { + category: ROUND_TRIP_CATEGORY_KEY, + categoryLabel: "Travel kit", + }, }); + const label = await prisma.medicationCategoryLabel.findUniqueOrThrow({ + where: { key: ROUND_TRIP_CATEGORY_KEY }, + }); + expect(label.isActive).toBe(false); }, ); diff --git a/tests/integration/medication-custom-categories.test.ts b/tests/integration/medication-custom-categories.test.ts new file mode 100644 index 000000000..694359423 --- /dev/null +++ b/tests/integration/medication-custom-categories.test.ts @@ -0,0 +1,299 @@ +/** + * Custom medication categories (#1041) against a real Postgres, through the + * shipped route exports. + * + * What the file pins: + * - a category is created, listed with its medication count, renamed and + * hidden through its own routes, and capped at twenty per account; + * - a medication can be filed under one of the caller's own categories and + * the list and detail reads publish the decrypted label beside the key; + * - another account's key is refused (422 `medications.category.unknown`) + * on create and on update, and never resolves on a read; + * - deleting a category moves its medications to OTHER in the same + * transaction, so no medication is left pointing at a key that is gone; + * - a key left behind anyway (written past the routes) reads as OTHER. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "user-med-categories"; +const OTHER_USER = "user-med-categories-other"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + for (const id of [OWNER, OTHER_USER]) { + await prisma.user.create({ + data: { + id, + username: id, + email: `${id}@example.test`, + timezone: "UTC", + }, + }); + } + await signIn(OWNER); +}); + +function req(method: string, path: string, body?: unknown) { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function createCategory(label: string): Promise { + const { POST } = await import("@/app/api/medications/categories/route"); + const res = await POST(req("POST", "/api/medications/categories", { label })); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(201); + return body.data.key as string; +} + +async function createMedication(category?: string) { + const { POST } = await import("@/app/api/medications/route"); + return POST( + req("POST", "/api/medications", { + name: "Tamiflu", + dose: "75 mg", + ...(category ? { category } : {}), + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); +} + +describe("custom medication categories", () => { + it("files a medication under an own category and publishes its label", async () => { + const key = await createCategory(" Travel kit "); + expect(key).toMatch(/^custom:[0-9a-f-]{36}$/); + + const created = await createMedication(key); + const createdBody = await created.json(); + expect(created.status, JSON.stringify(createdBody)).toBe(201); + expect(createdBody.data.category).toBe(key); + expect(createdBody.data.categoryLabel).toBe("Travel kit"); + + const { GET: list } = await import("@/app/api/medications/route"); + const listBody = await (await list()).json(); + expect(listBody.data[0]).toMatchObject({ + category: key, + categoryLabel: "Travel kit", + }); + + const { GET: detail } = await import("@/app/api/medications/[id]/route"); + const detailBody = await ( + await detail(req("GET", `/api/medications/${createdBody.data.id}`), { + params: Promise.resolve({ id: createdBody.data.id }), + }) + ).json(); + expect(detailBody.data).toMatchObject({ + category: key, + categoryLabel: "Travel kit", + }); + + const { GET: categories } = + await import("@/app/api/medications/categories/route"); + const categoriesBody = await (await categories()).json(); + expect(categoriesBody.data.categories).toEqual([ + { + key, + label: "Travel kit", + sortOrder: 0, + isActive: true, + medicationCount: 1, + }, + ]); + + // The label is ciphertext at rest. + const row = + await getPrismaClient().medicationCategoryLabel.findUniqueOrThrow({ + where: { key }, + }); + expect(Buffer.from(row.labelEncrypted).toString("utf8")).not.toContain( + "Travel kit", + ); + }); + + it("refuses another account's key on create and on update", async () => { + await signIn(OTHER_USER); + const foreignKey = await createCategory("Theirs"); + await signIn(OWNER); + + const refused = await createMedication(foreignKey); + const refusedBody = await refused.json(); + expect(refused.status).toBe(422); + expect(refusedBody.meta.errorCode).toBe("medications.category.unknown"); + expect(await getPrismaClient().medication.count()).toBe(0); + + const ok = await createMedication("VITAMIN"); + const okBody = await ok.json(); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const updated = await PUT( + req("PUT", `/api/medications/${okBody.data.id}`, { + category: foreignKey, + }), + { params: Promise.resolve({ id: okBody.data.id }) }, + ); + expect(updated.status).toBe(422); + expect((await updated.json()).meta.errorCode).toBe( + "medications.category.unknown", + ); + const assignment = + await getPrismaClient().medicationCategoryAssignment.findUniqueOrThrow({ + where: { medicationId: okBody.data.id }, + }); + expect(assignment.category).toBe("VITAMIN"); + }); + + it("keeps the category on an update that does not name it", 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}`, { name: "Renamed" }), + { 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" }); + }); + + it("renames, hides and caps", async () => { + const key = await createCategory("Heart"); + const { PATCH } = + await import("@/app/api/medications/categories/[key]/route"); + const res = await PATCH( + req("PATCH", `/api/medications/categories/${key}`, { + label: "Heart and vessels", + isActive: false, + sortOrder: 4, + }), + { params: Promise.resolve({ key }) }, + ); + expect(await res.json()).toMatchObject({ + data: { + key, + label: "Heart and vessels", + isActive: false, + sortOrder: 4, + medicationCount: 0, + }, + }); + + // Hidden ones count toward the cap: nineteen more fill it. + for (let i = 0; i < 19; i += 1) await createCategory(`Group ${i}`); + const { POST } = await import("@/app/api/medications/categories/route"); + const over = await POST( + req("POST", "/api/medications/categories", { label: "One too many" }), + ); + expect(over.status).toBe(422); + expect((await over.json()).meta.errorCode).toBe( + "medications.category.limitReached", + ); + }); + + it("does not let one account rename or delete another's category", async () => { + await signIn(OTHER_USER); + const foreignKey = await createCategory("Theirs"); + await signIn(OWNER); + const { PATCH, DELETE } = + await import("@/app/api/medications/categories/[key]/route"); + const patched = await PATCH( + req("PATCH", `/api/medications/categories/${foreignKey}`, { + label: "Mine now", + }), + { params: Promise.resolve({ key: foreignKey }) }, + ); + expect(patched.status).toBe(404); + const deleted = await DELETE( + req("DELETE", `/api/medications/categories/${foreignKey}`), + { params: Promise.resolve({ key: foreignKey }) }, + ); + expect(deleted.status).toBe(404); + expect( + await getPrismaClient().medicationCategoryLabel.count({ + where: { key: foreignKey }, + }), + ).toBe(1); + }); + + it("moves the medications of a deleted category to OTHER", async () => { + const key = await createCategory("Short courses"); + const first = await (await createMedication(key)).json(); + const second = await (await createMedication(key)).json(); + const untouched = await (await createMedication("THYROID")).json(); + + const { DELETE } = + await import("@/app/api/medications/categories/[key]/route"); + const res = await DELETE( + req("DELETE", `/api/medications/categories/${key}`), + { params: Promise.resolve({ key }) }, + ); + expect(await res.json()).toEqual({ + data: { key, movedCount: 2 }, + error: null, + }); + + const rows = await getPrismaClient().medicationCategoryAssignment.findMany({ + orderBy: { medicationId: "asc" }, + }); + const byId = Object.fromEntries( + rows.map((r) => [r.medicationId, r.category]), + ); + // The stored value itself, not what a read resolves it to: a dangling key + // would also READ as OTHER, which is exactly what this must not rely on. + expect(byId[first.data.id]).toBe("OTHER"); + expect(byId[second.data.id]).toBe("OTHER"); + expect(byId[untouched.data.id]).toBe("THYROID"); + expect(rows.filter((r) => r.category.startsWith("custom:"))).toEqual([]); + }); + + it("reads a key with no label as OTHER", async () => { + const created = await (await createMedication("VITAMIN")).json(); + await getPrismaClient().medicationCategoryAssignment.update({ + where: { medicationId: created.data.id }, + data: { category: "custom:00000000-0000-4000-8000-000000000000" }, + }); + const { GET } = await import("@/app/api/medications/[id]/route"); + const body = await ( + await GET(req("GET", `/api/medications/${created.data.id}`), { + params: Promise.resolve({ id: created.data.id }), + }) + ).json(); + expect(body.data).toMatchObject({ category: "OTHER", categoryLabel: null }); + }); +}); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index cb4fea195..32422711b 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -281,7 +281,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(78); }); }); @@ -2250,6 +2250,18 @@ proveManageRoute("POST /api/cycle/symptoms/custom", { })) === 1, }); +proveManageRoute("POST /api/medications/categories", { + route: "/api/medications/categories", + prepare: async (ownerId) => ({ ownerId }), + body: () => ({ label: "Travel kit" }), + ok: 201, + auditAction: "medication.category.custom.create", + applied: async ({ ownerId }) => + (await getPrismaClient().medicationCategoryLabel.count({ + where: { userId: ownerId }, + })) === 1, +}); + proveManageRoute("POST /api/nutrients/water", { route: "/api/nutrients/water", prepare: async (ownerId) => { @@ -2580,13 +2592,13 @@ describe("the conditions the admissions were granted on", () => { describe("the complete MANAGE handler matrix", () => { it("registers one strict actor-and-effect driver for every admission", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(STRICT_DRIVER_KEYS.size).toBe(77); + expect(STRICT_DRIVER_KEYS.size).toBe(78); expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected); }); it("executes every registered driver through its owned effect and actor audit", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(REAL_EFFECT_KEYS.size).toBe(77); + expect(REAL_EFFECT_KEYS.size).toBe(78); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); });