mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
1 parent
350c646463
commit
4cb8118802
55 files changed
+3201
-187
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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() }),
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user