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

+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(