Files
HealthLog/src/lib/medication-category.ts
T
Marc-André Bombeck 4cb8118802 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.
2026-10-03 21:38:03 +02:00

310 lines
9.6 KiB
TypeScript

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 { 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";
/**
* The client surface the helpers below need. The default is the shared
* client; the backup builder and the restore pass their own (the restore's
* transaction client, so a category row lands in the same transaction as
* the medication it points at and its foreign key can see that row).
*/
type CategoryClient = Pick<
Prisma.TransactionClient,
"medicationCategoryAssignment" | "medicationCategoryLabel"
>;
function isBuiltIn(input: string): input is MedicationCategory {
return MEDICATION_CATEGORIES.includes(input as MedicationCategory);
}
/**
* 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, ResolvedMedicationCategory>> {
if (medicationIds.length === 0) return {};
const rows = await client.medicationCategoryAssignment.findMany({
where: { medicationId: { in: medicationIds } },
select: {
medicationId: true,
category: true,
medication: { select: { userId: true } },
},
});
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] = { category: DEFAULT_CATEGORY, categoryLabel: null };
}
let dangling = 0;
for (const row of rows) {
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 &
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 },
create: { medicationId, category: normalized },
update: { category: normalized },
});
return normalized;
}
export async function deleteMedicationCategory(
medicationId: string,
client: CategoryClient = prisma,
) {
await client.medicationCategoryAssignment.deleteMany({
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 };
});
}