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: