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:<uuid> 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:<uuid> 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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 21:38:03 +02:00
1 parent 350c646463
commit 4cb8118802
55 files changed
+3201 -187

No files matched your search

+363 -51
View File
@@ -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:<uuid>` 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:<uuid>`, the category's key."
required: true
description: "`custom:<uuid>`, 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:<uuid>`, the category's key."
required: true
description: "`custom:<uuid>`, 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:<uuid>`, 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:<uuid>`, 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:<uuid>`); 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:<uuid>`, 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:<uuid>`); 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:<uuid>`, 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:
@@ -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");
+29
View File
@@ -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:<uuid>`) 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:<uuid>`, 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
+9
View File
@@ -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
@@ -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",
+15 -2
View File
@@ -1023,6 +1023,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
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<string, ManageEntry> = {
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<string, string> = {
* 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 }) =>
@@ -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,
},
@@ -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(
@@ -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<typeof vi.fn>
).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 },
});
}
+7 -2
View File
@@ -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,
@@ -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",
+21 -7
View File
@@ -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,
});
},
);
@@ -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);
@@ -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<boolean> {
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 });
},
);
+126
View File
@@ -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:<uuid>` 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<Response> {
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,
);
}
+26 -4
View File
@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
),
),
category: normalizedCategory,
categoryLabel,
},
201,
);
+10 -7
View File
@@ -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:<uuid>` (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 (
<Card key={med.id} className="gap-2 py-3 md:py-4">
<CardHeader>
@@ -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<typeof med>[],
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(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>
<MedicationsPage />
</QueryClientProvider>
</I18nProvider>,
);
}
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"');
});
});
+143 -8
View File
@@ -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<string | null>(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 && (
<FilterBar
isFiltered={effectiveCategoryFilter !== ALL_CATEGORIES}
onReset={() => setCategoryFilter(ALL_CATEGORIES)}
>
<FilterBarSelect
label={t("medications.category.filter.label")}
value={effectiveCategoryFilter}
onValueChange={setCategoryFilter}
options={categoriesInUse}
allValue={ALL_CATEGORIES}
allLabel={t("medications.category.filter.all")}
/>
{canManageCategories && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground min-h-11 shrink-0 rounded-full sm:min-h-9"
onClick={() => setManageCategoriesOpen(true)}
data-slot="medication-categories-manage"
>
{t("medications.category.custom.manage")}
</Button>
)}
</FilterBar>
)}
{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.
<MedicationTable
activeMedications={activeMeds}
inactiveMedications={inactiveMeds}
activeMedications={shownActiveMeds}
inactiveMedications={shownInactiveMeds}
highlightId={highlightId}
/>
) : (
<div className="space-y-6">
{/* Active medications */}
{activeMeds.length > 0 && (
{shownActiveMeds.length > 0 && (
<div className="space-y-3.5">
<div className="grid gap-4 sm:grid-cols-2">
{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 && (
<div className="space-y-3.5">
<h2 className="text-muted-foreground text-sm font-medium">
{t("common.inactive")} ({inactiveMeds.length})
{t("common.inactive")} ({shownInactiveMeds.length})
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{inactiveMeds.map((med) =>
{shownInactiveMeds.map((med) =>
med.treatmentClass === "GLP1" && !med.asNeeded ? (
<Glp1MedicationCard
key={med.id}
@@ -654,6 +782,13 @@ export default function MedicationsPageClient() {
/>
)}
{manageCategoriesOpen && canManageCategories && (
<MedicationCategoriesSheet
open={manageCategoriesOpen}
onOpenChange={setManageCategoriesOpen}
/>
)}
{logIntakeOpen && (
<LogIntakeDialog
open={logIntakeOpen}
@@ -135,6 +135,8 @@ export interface MedicationDetailSnapshot {
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;
deliveryForm?: string;
dosesPerUnit?: number | null;
@@ -96,6 +96,8 @@ export interface Glp1Medication {
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;
dosesPerUnit?: number | null;
/** v1.6.0 — route of administration (drives the injection-site prompt). */
@@ -476,7 +478,11 @@ export function Glp1MedicationCard({
/>
);
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
@@ -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) ||
@@ -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 (
<div
className="border-border space-y-2 rounded-md border p-3"
data-slot="medication-category-rename"
>
<Input
value={label}
maxLength={40}
autoFocus
aria-label={t("medications.category.custom.label")}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void rename();
}
}}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => {
setLabel(category.label);
setEditing(false);
}}
>
{t("common.cancel")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={!label.trim() || update.isPending}
onClick={() => void rename()}
>
{update.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
{t("common.save")}
</Button>
</div>
</div>
);
}
return (
<div
data-slot="medication-category-row"
data-key={category.key}
className="border-border flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
>
<Tag
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-sm" title={category.label}>
{category.label}
</span>
{!category.isActive && (
<Badge variant="secondary">
{t("medications.category.custom.hidden")}
</Badge>
)}
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{t("medications.category.custom.count", {
count: String(category.medicationCount),
})}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => setEditing(true)}
aria-label={`${t("medications.category.custom.rename")}: ${category.label}`}
title={t("medications.category.custom.rename")}
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
disabled={update.isPending}
onClick={() => void toggleHidden()}
aria-label={`${
category.isActive
? t("medications.category.custom.hide")
: t("medications.category.custom.show")
}: ${category.label}`}
title={
category.isActive
? t("medications.category.custom.hide")
: t("medications.category.custom.show")
}
>
{category.isActive ? (
<EyeOff className="size-4" aria-hidden="true" />
) : (
<Eye className="size-4" aria-hidden="true" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive size-11 sm:size-9"
onClick={() => onDelete(category)}
aria-label={`${t("medications.category.custom.delete")}: ${category.label}`}
title={t("medications.category.custom.delete")}
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</div>
);
}
export function MedicationCategoriesSheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const categories = useMedicationCategories();
const remove = useDeleteMedicationCategory();
const [pendingDelete, setPendingDelete] =
useState<MedicationCategoryLabelDTO | null>(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 (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("medications.category.custom.manage")}
description={t("medications.category.custom.manageDescription")}
>
<div className="space-y-2" data-slot="medication-categories-sheet">
{categories.isError ? (
<QueryErrorRow onRetry={() => void categories.refetch()} />
) : (
(categories.data ?? []).map((category) => (
<CategoryRow
key={category.key}
category={category}
onDelete={setPendingDelete}
/>
))
)}
<AddMedicationCategoryRow />
<p className="text-muted-foreground text-xs">
{t("medications.category.custom.bpGateNote")}
</p>
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(next) => {
if (!next) setPendingDelete(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.category.custom.deleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.category.custom.deleteBody", {
count: String(pendingDelete?.medicationCount ?? 0),
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
const category = pendingDelete;
setPendingDelete(null);
if (category) void confirmDelete(category);
}}
>
{t("medications.category.custom.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ResponsiveSheet>
);
}
@@ -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 (
<button
type="button"
onClick={() => setOpen(true)}
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md border border-dashed p-3 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
data-slot="wizard-class-add"
>
<Plus className="h-5 w-5 shrink-0" aria-hidden="true" />
{t("medications.category.custom.add")}
</button>
);
}
return (
<div
className="space-y-2 rounded-md border p-3"
data-slot="wizard-class-add-form"
>
<Input
value={label}
maxLength={40}
autoFocus
aria-label={t("medications.category.custom.label")}
placeholder={t("medications.category.custom.labelPlaceholder")}
onChange={(e) => 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 ? (
<p className="text-destructive text-sm" role="alert">
{isCategoryLimitError(create.error)
? t("medications.category.custom.limitReached")
: t("medications.category.custom.saveFailed")}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={close}
>
{t("common.cancel")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => void submit()}
disabled={!label.trim() || create.isPending}
>
{create.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
{t("medications.category.custom.create")}
</Button>
</div>
</div>
);
}
@@ -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<MedicationCategoryLabelDTO>("/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<MedicationCategoryLabelDTO>(
`/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),
});
}
@@ -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();
});
});
@@ -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<WizardTreatmentRow, ComponentType<LucideProps>> = {
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<LucideProps>;
selected: boolean;
onSelect: () => void;
slot: "wizard-class-row" | "wizard-custom-class-row";
}) {
return (
<label
className={[
"flex min-h-11 cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
data-slot={slot}
data-row={value}
data-selected={selected ? "true" : "false"}
>
<input
type="radio"
name="wizard-class"
value={value}
checked={selected}
onChange={onSelect}
className="sr-only"
aria-label={label}
/>
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate text-sm font-medium">{label}</span>
</label>
);
}
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 (
<div
role="radiogroup"
@@ -57,49 +113,53 @@ export function Step2Class({ payload, applyPartial }: StepProps) {
className="space-y-2"
data-slot="wizard-step2"
>
{WIZARD_TREATMENT_ROWS.map((row) => {
const Icon = ROW_ICONS[row];
const selected = payload.treatmentRow === row;
const label = t(`medications.wizard.classRow.${row}`);
return (
<label
key={row}
className={[
"flex min-h-11 cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
data-slot="wizard-class-row"
data-row={row}
data-selected={selected ? "true" : "false"}
>
<input
type="radio"
name="wizard-class"
value={row}
checked={selected}
onChange={() =>
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, deliveryForm: "INJECTION" }
: { treatmentRow: row },
)
}
className="sr-only"
aria-label={label}
/>
<Icon
className="text-primary h-5 w-5 shrink-0"
aria-hidden="true"
/>
<span className="text-sm font-medium">{label}</span>
</label>
);
})}
{WIZARD_TREATMENT_ROWS.map((row) => (
<ClassRow
key={row}
value={row}
label={t(`medications.wizard.classRow.${row}`)}
Icon={ROW_ICONS[row]}
selected={payload.treatmentRow === row && customKey === null}
slot="wizard-class-row"
onSelect={() =>
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) => (
<ClassRow
key={c.key}
value={c.key}
label={c.label}
Icon={Tag as unknown as LucideIcon}
selected={customKey === c.key}
slot="wizard-custom-class-row"
onSelect={() =>
applyPartial({ treatmentRow: "other", customCategoryKey: c.key })
}
/>
))}
{canManageDomain("medications") && (
<AddMedicationCategoryRow
onCreated={(key) =>
applyPartial({ treatmentRow: "other", customCategoryKey: key })
}
/>
)}
<p className="text-muted-foreground text-xs">
{t("medications.category.custom.bpGateNote")}
</p>
</div>
);
}
@@ -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
@@ -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();
});
});
+2 -1
View File
@@ -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" },
+3
View File
@@ -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,
@@ -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", () => ({
@@ -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", () => ({
+4
View File
@@ -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",
+11 -1
View File
@@ -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
? {
+38 -2
View File
@@ -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<string, string>();
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 ────────────────
+19
View File
@@ -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<Record<string, unknown>>)[0],
).toMatchObject({
category: "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
categoryLabel: "<<<USER_TEXT_START>>>Travel kit<<<USER_TEXT_END>>>",
});
});
it("returns { present: false } when no medications are tracked", async () => {
+15
View File
@@ -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:<uuid>`, 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.
+252 -16
View File
@@ -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:<uuid>` 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<Record<string, MedicationCategory>> {
): Promise<Record<string, ResolvedMedicationCategory>> {
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<string, MedicationCategory> = {};
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<string, ResolvedMedicationCategory> = {};
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<Record<string, MedicationCategoryKey>> {
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<boolean> {
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.TransactionClient, "medication"> = prisma,
): Promise<MedicationCategoryKey> {
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<ArrayBuffer> {
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<MedicationCategoryLabelDto[]> {
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 };
});
}
@@ -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",
);
});
});
+66
View File
@@ -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, string | number>,
) => 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<string, string>();
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<T extends CategorisedMedication>(
medications: readonly T[],
filter: string,
): T[] {
return filter === ALL_MEDICATION_CATEGORIES
? [...medications]
: medications.filter((m) => m.category === filter);
}
+13
View File
@@ -38,10 +38,23 @@ const MEDICATION_CATEGORY_KEYS: Record<string, string> = {
OTHER: "medications.categoryOther",
};
/**
* v1.40 (#1041) — a category the person named themselves arrives as
* `custom:<uuid>` 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,
);
+12 -4
View File
@@ -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<string, string> = {};
let categoryMap: Record<string, ResolvedMedicationCategory> = {};
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
+3
View File
@@ -249,6 +249,9 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
],
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",
+142 -2
View File
@@ -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:<uuid>`, 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<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "Create a medication with at least one schedule",
description:
"Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`. 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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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:<uuid>` 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<ZodOpenApiObject["paths"]> = {
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() }),
},
+45 -3
View File
@@ -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:<uuid>` 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:<uuid>\`, 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:<uuid>`); null for a built-in category, which a client localises from `category`. Read-only.",
);
export const medicationCategoryLabelResource = z
.object({
key: z
.string()
.describe(
"`custom:<uuid>`, 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(
+7
View File
@@ -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) =>
+4
View File
@@ -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":
+22 -1
View File
@@ -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:<uuid>`, 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
+59 -2
View File
@@ -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:<uuid>`. 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:<uuid>`, 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
@@ -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.
+8
View File
@@ -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",
+51 -7
View File
@@ -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<void> {
},
});
// 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);
},
);
@@ -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<string> {
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 });
});
});
@@ -281,7 +281,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
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);
});
});