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

@@ -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,
);