From 350c64646362559d042b8548c96f588858a76874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 20:45:02 +0200 Subject: [PATCH 01/18] Adopt the medication category table into the schema The clinical category lived in a side table the app created at runtime, after migration 0004 had dropped it as orphaned. It is now the MedicationCategoryAssignment model. Migration 0368 creates the table IF NOT EXISTS with the runtime DDL, so an existing table converges with its rows untouched (verified with prisma migrate diff against a database holding the runtime table and against a fresh one). The helpers keep their names and run on the Prisma delegate; the runtime CREATE TABLE and the pre-transaction ensure call in the restore are gone. The backup plan judges the table as a schema model (backed up, two-ended, seeded in the round trip) instead of under RAW_SQL_TABLES, which is now empty; its guard proves its matcher on a fixture string instead. --- .../migration.sql | 20 +++++ prisma/schema.prisma | 62 +++++++++----- .../backup-plan-classification.test.ts | 39 +++++++-- .../__tests__/full-backup-payload.test.ts | 1 - .../restore-backup-round-trip.test.ts | 1 - src/lib/export/backup-plan.ts | 24 ++++-- src/lib/export/full-backup-payload.ts | 7 +- src/lib/export/restore-backup.ts | 11 +-- src/lib/medication-category.ts | 85 ++++++------------- tests/integration/backup-round-trip.test.ts | 8 ++ 10 files changed, 150 insertions(+), 108 deletions(-) create mode 100644 prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql diff --git a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql new file mode 100644 index 000000000..d649406d1 --- /dev/null +++ b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql @@ -0,0 +1,20 @@ +-- Medication categories: adopt the side table into the schema. +-- +-- `medication_categories` used to be created by the app at runtime (migration +-- 0004 had dropped it as orphaned and the app recreated it on first use). It +-- is now a Prisma model. The DDL below is identical to what the runtime +-- helper ran, and Postgres gives the unnamed primary key, foreign key and +-- index the same names Prisma expects, so a database that already holds the +-- runtime table keeps it untouched and every row in it; a fresh database gets +-- the same table. Idempotent. +CREATE TABLE IF NOT EXISTS "medication_categories" ( + "medication_id" TEXT NOT NULL, + "category" TEXT NOT NULL DEFAULT 'OTHER', + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "medication_categories_pkey" PRIMARY KEY ("medication_id"), + CONSTRAINT "medication_categories_medication_id_fkey" + FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "medication_categories_category_idx" + ON "medication_categories"("category"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36dbabbce..c7cd9569c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2753,33 +2753,33 @@ model Medication { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - schedules MedicationSchedule[] - intakeEvents MedicationIntakeEvent[] - intakeImportJobs MedicationIntakeImportJob[] - phaseConfig ReminderPhaseConfig? - telegramMessages TelegramReminderMessage[] - doseChanges MedicationDoseChange[] - inventoryEvents MedicationInventoryEvent[] + schedules MedicationSchedule[] + intakeEvents MedicationIntakeEvent[] + intakeImportJobs MedicationIntakeImportJob[] + phaseConfig ReminderPhaseConfig? + telegramMessages TelegramReminderMessage[] + doseChanges MedicationDoseChange[] + inventoryEvents MedicationInventoryEvent[] /// v1.4.25 W19b — per-pen / per-vial entities (state machine + /// 30-day in-use clock). Complements the running-sum /// MedicationInventoryEvent ledger. - inventoryItems MedicationInventoryItem[] + inventoryItems MedicationInventoryItem[] /// v1.4.25 W19d — structured GLP-1 side-effect logs. Surfaced on /// the medication detail page for GLP-1 treatmentClass only; the /// taxonomy follows EMA EPAR §4.8 with HealthLog's 5-category /// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE / /// GLP1_SPECIFIC) and 1-5 Likert severity per entry. - sideEffects MedicationSideEffect[] + sideEffects MedicationSideEffect[] /// v1.4.39 W-MED — per-(medication, day) compliance rollup. Mutations /// on the linked MedicationIntakeEvent rows refresh the row inline; /// the read paths consume this instead of the per-event findMany. - complianceRollups MedicationComplianceRollup[] + complianceRollups MedicationComplianceRollup[] /// v1.16.3 — archived schedule eras (effective dating). Superseded /// schedule states land here when a wholesale schedule replace changes /// cadence-relevant fields; the band minter segments historical minting /// ranges into eras so past days read against the schedule that was /// live THEN, not the current one. - scheduleRevisions MedicationScheduleRevision[] + scheduleRevisions MedicationScheduleRevision[] /// v1.25 — durable pause intervals. A pause stamps an open era /// (`resumedAt = null`); resume closes the latest open era. The /// compliance engine drops expected slots whose anchor falls inside a @@ -2787,12 +2787,15 @@ model Medication { /// as missed. The `pausedAt` column on this model stays the live /// single-pause marker the reminder/today surfaces read; the era table /// is the additive durable record the rate paths need. - pauseEras MedicationPauseEra[] + pauseEras MedicationPauseEra[] /// v1.28 — user-pinned efficacy target(s) for the "Wirkung" view. Rows /// exist ONLY when the user explicitly overrides the derived (ATC / name) /// target resolution; absence means the resolver derives the target each /// read. Nothing about a class→outcome association is denormalised here. - efficacyTargets MedicationEfficacyTarget[] + efficacyTargets MedicationEfficacyTarget[] + /// v1.40 — the clinical category the person filed this medication under + /// (one row at most; absence reads as OTHER). + categoryAssignment MedicationCategoryAssignment? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2808,6 +2811,27 @@ model Medication { @@map("medications") } +/// v1.40 — the clinical category of a medication (BLOOD_PRESSURE, VITAMIN, +/// MENTAL_HEALTH, ... or a `custom:` key of one of the owner's +/// `MedicationCategoryLabel` rows). The table predates this model: it was a +/// raw-SQL side table the app created at runtime, which migration 0004 had +/// dropped as orphaned. Migration 0368 creates it `IF NOT EXISTS` with the +/// same DDL, so a database that already holds the runtime table converges +/// without drift. The foreign key keeps the runtime table's `NO ACTION` on +/// update. Absence of a row reads as OTHER. +model MedicationCategoryAssignment { + medicationId String @id @map("medication_id") + /// A built-in value from `MEDICATION_CATEGORY_VALUES` or `custom:`. + /// Only `src/lib/medication-category.ts` reads or writes it. + category String @default("OTHER") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([category]) + @@map("medication_categories") +} + /// v1.28 — the user's explicit override of a medication's efficacy target, /// the ONE thing the "Wirkung" view persists. Class→outcome resolution /// (ATC-prefix → name inference) is computed each read from the closed @@ -6203,19 +6227,19 @@ model RecommendationFeedback { // the user explicitly opted out of into it. The summarisation helper // in `src/lib/ai/coach/persistence.ts` truncates with an ellipsis. model CoachConversation { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// @internal: legacy plaintext title, read only as a fallback for rows the /// free-text encryption backfill has not reached yet (v1.39.3). Every write /// since then goes to `titleEncrypted` and leaves this NULL; the backfill /// nulls it on the old rows. DROP this column in a later release, once the /// backfill reports zero remaining rows on every instance. - title String? + title String? /// v1.39.3 — AES-256-GCM ciphertext of the title (the first 80 characters of /// the opening Coach message, or the rename). Same Bytes codec as the notes. - titleEncrypted Bytes? @map("title_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + titleEncrypted Bytes? @map("title_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") // v1.11.0 — rolling encrypted summary of the elided older turns, so a long // conversation keeps memory of what came before without re-sending every diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 99b3390e7..6af03054a 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -508,14 +508,17 @@ describe("tables outside the schema have a backup verdict too", () => { }); } + const CREATE_TABLE = /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+"?(\w+)"?/gi; + + function tablesIn(text: string): string[] { + return [...text.matchAll(CREATE_TABLE)].map((m) => m[1]); + } + function rawTables(): string[] { const names = new Set(); for (const file of sourceFiles(SRC)) { - const text = readFileSync(file, "utf8"); - for (const m of text.matchAll( - /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+"?(\w+)"?/gi, - )) { - names.add(m[1]); + for (const name of tablesIn(readFileSync(file, "utf8"))) { + names.add(name); } } return [...names]; @@ -527,8 +530,30 @@ describe("tables outside the schema have a backup verdict too", () => { ), ); - it("finds the raw tables it is meant to judge (no vacuous pass)", () => { - expect(rawTables()).toContain("medication_categories"); + it("finds a raw table when one is written (no vacuous pass)", () => { + // The source holds none since v1.40, so the matcher is proven against the + // exact DDL the medication category helper used to run at runtime. + expect( + tablesIn(` + CREATE TABLE IF NOT EXISTS medication_categories ( + medication_id TEXT PRIMARY KEY + ); + create table if not exists "quoted_name" (id TEXT); + `), + ).toEqual(["medication_categories", "quoted_name"]); + }); + + it("keeps no verdict for a raw table the source no longer creates", () => { + const present = new Set(rawTables()); + expect(Object.keys(RAW_SQL_TABLES).filter((t) => !present.has(t))).toEqual( + [], + ); + }); + + it("judges the adopted medication category table as a schema model", () => { + expect(mappedTables.has("medication_categories")).toBe(true); + expect(BACKED_UP_MODELS).toContain("MedicationCategoryAssignment"); + expect(TWO_ENDED_MODELS).toContain("MedicationCategoryAssignment"); }); it("gives every raw table that is not a schema model a verdict", () => { diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index c4a65b1ec..0471bf2d9 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -15,7 +15,6 @@ const mocks = vi.hoisted(() => ({ // The category side table is raw SQL on the shared client; the round trip // through it is proven in tests/integration/backup-round-trip.test.ts. vi.mock("@/lib/medication-category", () => ({ - ensureMedicationCategoryTable: vi.fn(async () => {}), getMedicationCategories: vi.fn(async () => ({})), setMedicationCategory: vi.fn(async () => "OTHER"), })); diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index a988ddfed..d95ed71c8 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -34,7 +34,6 @@ const mocks = vi.hoisted(() => ({ // The category side table is raw SQL on the shared client; the round trip // through it is proven in tests/integration/backup-round-trip.test.ts. vi.mock("@/lib/medication-category", () => ({ - ensureMedicationCategoryTable: vi.fn(async () => {}), getMedicationCategories: vi.fn(async () => ({})), setMedicationCategory: vi.fn(async () => "OTHER"), })); diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 6af2a3d66..a49e803ff 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -77,6 +77,12 @@ export const BACKED_UP_MODELS = [ "MedicationEfficacyTarget", "MedicationInventoryItem", "MedicationInventoryEvent", + // The clinical category the person filed each medication under. Before + // v1.40 this was a raw table the app created at runtime and judged under + // RAW_SQL_TABLES; it rides inside its medication (`category`) in both + // formats and is written back in the restore transaction. Without it every + // restored medication reads as Other. + "MedicationCategoryAssignment", // ── Mood ────────────────────────────────────────────────────────────────── "MoodEntry", @@ -251,6 +257,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // tables and not a parent-child ride. "src/lib/export/ecg-backup.ts", "src/lib/cycle/backup.ts", + // The medication category is read through the one helper that normalises + // it; the payload writer calls the helper rather than the delegate. The + // same "the caller delegates" shape as the measurement pager. + "src/lib/medication-category.ts", ]; export const BACKUP_RESTORE_FILES: readonly string[] = [ @@ -269,6 +279,8 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/onboarding-backup.ts", "src/lib/export/ecg-backup.ts", "src/lib/cycle/backup.ts", + // The restore writes the medication category through the same helper. + "src/lib/medication-category.ts", ]; /** @@ -297,6 +309,7 @@ export const TWO_ENDED_MODELS = [ "MedicationSideEffect", "MedicationPauseEra", "MedicationDoseChange", + "MedicationCategoryAssignment", "MoodEntry", "MoodContext", "MoodEntryTagLink", @@ -715,16 +728,13 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { * every backup until v1.39.4. The guard beside this file finds each * `CREATE TABLE IF NOT EXISTS` in the source and demands a verdict here, or a * schema model mapped to the same name. + * + * Empty since v1.40: the one such table, `medication_categories`, is the + * `MedicationCategoryAssignment` model now and judged with the others. */ export const RAW_SQL_TABLES: Readonly< Record -> = { - medication_categories: { - verdict: "BACKED_UP", - reason: - "The clinical category the person chose for each medication. Carried inside its medication in both backup formats and written back in the restore transaction; without it every restored medication reads as Other.", - }, -}; +> = {}; /** Auth material: not user data, and excluded from the wipe for the same reason. */ export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [ diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 5f5e35fed..aaec42083 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -962,9 +962,10 @@ export async function buildFullBackupPayload( : moodEntries!, }; - // The clinical category lives in a raw side table rather than on the - // medication row, so no Prisma include reaches it. Before v1.39.4 no backup - // carried it and every restored medication came back as "Other". + // The clinical category lives in its own table rather than on the + // medication row and is read through the one helper that normalises it. + // Before v1.39.4 no backup carried it and every restored medication came + // back as "Other". const categoryByMedication = await getMedicationCategories( medications.map((m) => m.id), prisma, diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 8b80622d1..0dbfb4bd2 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -107,10 +107,7 @@ import { restoreAwardsData } from "@/lib/export/awards-backup"; import { restoreEnvironmentData } from "@/lib/export/environment-backup"; import { restoreEcgData } from "@/lib/export/ecg-backup"; import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at"; -import { - ensureMedicationCategoryTable, - setMedicationCategory, -} from "@/lib/medication-category"; +import { setMedicationCategory } from "@/lib/medication-category"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; import { stampSyncReset } from "@/lib/sync/reset"; @@ -682,10 +679,6 @@ export async function restoreBackup( skipped: RestoreSkipSummary; accountSettings: AccountSettingsRestoreResult; }; - // The category side table is created lazily. Doing that here, before the - // transaction, keeps its DDL (which takes a table lock) out of a - // transaction that already holds row locks on the same table. - await ensureMedicationCategoryTable(); try { report("clearing"); outcome = await prisma.$transaction( @@ -1265,7 +1258,7 @@ export async function restoreBackup( }); restoredMedicationIds.add(created.id); if (!medByName.has(m.name)) medByName.set(m.name, created.id); - // v1.39.4 — the clinical category lives in a side table keyed on the + // v1.39.4 — the clinical category lives in its own table keyed on the // medication id, so it is written after the row exists and inside the // same transaction. OTHER is what a missing row already reads as. if (m.category && m.category !== "OTHER") { diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index 04d64d771..ad4cd1c8b 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -1,3 +1,4 @@ +import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication"; @@ -8,18 +9,16 @@ export type MedicationCategory = (typeof MEDICATION_CATEGORIES)[number]; const DEFAULT_CATEGORY: MedicationCategory = "OTHER"; /** - * The raw-SQL surface the helpers below need. The default is the shared + * 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 RawSqlClient = Pick< - typeof prisma, - "$queryRawUnsafe" | "$executeRawUnsafe" +type CategoryClient = Pick< + Prisma.TransactionClient, + "medicationCategoryAssignment" >; -let ensureTablePromise: Promise | null = null; - function normalizeCategory(input: unknown): MedicationCategory { if (typeof input !== "string") return DEFAULT_CATEGORY; return MEDICATION_CATEGORIES.includes(input as MedicationCategory) @@ -27,54 +26,23 @@ function normalizeCategory(input: unknown): MedicationCategory { : DEFAULT_CATEGORY; } -export async function ensureMedicationCategoryTable() { - if (!ensureTablePromise) { - ensureTablePromise = (async () => { - await prisma.$executeRawUnsafe(` - CREATE TABLE IF NOT EXISTS medication_categories ( - medication_id TEXT PRIMARY KEY REFERENCES medications(id) ON DELETE CASCADE, - category TEXT NOT NULL DEFAULT 'OTHER', - updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - `); - - await prisma.$executeRawUnsafe(` - CREATE INDEX IF NOT EXISTS medication_categories_category_idx - ON medication_categories(category); - `); - })().catch((err) => { - ensureTablePromise = null; - throw err; - }); - } - - await ensureTablePromise; -} - export async function getMedicationCategories( medicationIds: string[], - client: RawSqlClient = prisma, + client: CategoryClient = prisma, ): Promise> { if (medicationIds.length === 0) return {}; - await ensureMedicationCategoryTable(); - const rows = await client.$queryRawUnsafe< - Array<{ medication_id: string; category: string }> - >( - ` - SELECT medication_id, category - FROM medication_categories - WHERE medication_id = ANY($1::text[]) - `, - medicationIds, - ); + const rows = await client.medicationCategoryAssignment.findMany({ + where: { medicationId: { in: medicationIds } }, + select: { medicationId: true, category: true }, + }); const map: Record = {}; for (const id of medicationIds) { map[id] = DEFAULT_CATEGORY; } for (const row of rows) { - map[row.medication_id] = normalizeCategory(row.category); + map[row.medicationId] = normalizeCategory(row.category); } return map; } @@ -82,29 +50,24 @@ export async function getMedicationCategories( export async function setMedicationCategory( medicationId: string, category: unknown, - client: RawSqlClient = prisma, + client: CategoryClient = prisma, ) { - await ensureMedicationCategoryTable(); const normalized = normalizeCategory(category); - await client.$executeRawUnsafe( - ` - INSERT INTO medication_categories (medication_id, category, updated_at) - VALUES ($1, $2, NOW()) - ON CONFLICT (medication_id) - DO UPDATE SET category = EXCLUDED.category, updated_at = NOW() - `, - medicationId, - normalized, - ); + await client.medicationCategoryAssignment.upsert({ + where: { medicationId }, + create: { medicationId, category: normalized }, + update: { category: normalized }, + }); return normalized; } -export async function deleteMedicationCategory(medicationId: string) { - await ensureMedicationCategoryTable(); - await prisma.$executeRawUnsafe( - `DELETE FROM medication_categories WHERE medication_id = $1`, - medicationId, - ); +export async function deleteMedicationCategory( + medicationId: string, + client: CategoryClient = prisma, +) { + await client.medicationCategoryAssignment.deleteMany({ + where: { medicationId }, + }); } diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 2ae7458d8..6d082054d 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -220,6 +220,9 @@ const COUNT_BACK: Record< // No own `userId` column — reached through the drug, like the schedules. MedicationDoseChange: (p, userId) => p.medicationDoseChange.count({ where: { medication: { userId } } }), + // No own `userId` column — reached through the drug, like the schedules. + MedicationCategoryAssignment: (p, userId) => + p.medicationCategoryAssignment.count({ where: { medication: { userId } } }), MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }), MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }), MoodEntryTagLink: (p, userId) => @@ -423,6 +426,11 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { takenAt: AT("2026-07-01T08:04:00.000Z"), }, }); + // Not OTHER: a missing row already reads as OTHER, so only a chosen value + // proves the category came back. + await prisma.medicationCategoryAssignment.create({ + data: { medicationId: medication.id, category: "THYROID" }, + }); // The one row in this fixture that is also checked field by field after the // restore — see the assertion at the end of the test for why. await prisma.medicationSideEffect.create({ From 4cb81188022be5a033a42da9c8d9d9b61ca870b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 21:38:03 +0200 Subject: [PATCH 02/18] 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: 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: 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. --- docs/api/openapi.yaml | 414 +++++++++++++++--- .../migration.sql | 22 + prisma/schema.prisma | 29 ++ scripts/rotate-encryption-key.ts | 9 + .../destructive-control-guard.test.ts | 8 + src/__tests__/sharing-surface-guard.test.ts | 17 +- .../success-affordance-guard.test.ts | 4 + src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- .../comprehensive/__tests__/route.test.ts | 12 +- src/app/api/insights/comprehensive/route.ts | 9 +- .../medications/[id]/__tests__/route.test.ts | 17 +- src/app/api/medications/[id]/route.ts | 28 +- .../api/medications/__tests__/route.test.ts | 17 +- .../api/medications/categories/[key]/route.ts | 151 +++++++ src/app/api/medications/categories/route.ts | 126 ++++++ src/app/api/medications/route.ts | 30 +- src/app/insights/medications/page.tsx | 17 +- .../__tests__/category-filter.test.tsx | 140 ++++++ src/app/medications/page-client.tsx | 151 ++++++- .../detail/medication-detail-tabs.tsx | 2 + .../medications/glp1-medication-card.tsx | 8 +- .../medications/medication-card.tsx | 8 +- .../medication-categories-sheet.tsx | 285 ++++++++++++ .../medication-category-add-row.tsx | 122 ++++++ .../medications/use-medication-categories.ts | 97 ++++ .../wizard/__tests__/wizard-payload.test.ts | 82 ++++ .../medications/wizard/steps/step2-class.tsx | 146 ++++-- .../medications/wizard/wizard-payload.ts | 49 ++- src/lib/__tests__/medication-category.test.ts | 179 ++++++++ src/lib/crypto/encrypted-columns.ts | 3 +- src/lib/data-wipe/wipe-plan.ts | 3 + .../__tests__/full-backup-payload.test.ts | 1 + .../restore-backup-round-trip.test.ts | 3 + src/lib/export/backup-plan.ts | 4 + src/lib/export/full-backup-payload.ts | 12 +- src/lib/export/restore-backup.ts | 40 +- src/lib/mcp/__tests__/resources.test.ts | 19 + src/lib/mcp/resources.ts | 15 + src/lib/medication-category.ts | 268 +++++++++++- .../__tests__/category-filter.test.ts | 65 +++ src/lib/medications/category-filter.ts | 66 +++ src/lib/medications/category-label.ts | 13 + src/lib/medications/list-read.ts | 16 +- src/lib/openapi/error-codes.ts | 3 + src/lib/openapi/routes/medications/paths.ts | 144 +++++- src/lib/openapi/routes/medications/schemas.ts | 48 +- src/lib/query-keys/medications.ts | 7 + src/lib/record-activity/activity-verb.ts | 4 + src/lib/validations/backup.ts | 23 +- src/lib/validations/medication/base.ts | 61 ++- .../validations/medication/create-update.ts | 6 +- tests/fixtures/v137/sharing-matrix.ts | 8 + tests/integration/backup-round-trip.test.ts | 58 ++- .../medication-custom-categories.test.ts | 299 +++++++++++++ .../sharing-manage-handler-matrix.test.ts | 18 +- 55 files changed, 3201 insertions(+), 187 deletions(-) create mode 100644 src/app/api/medications/categories/[key]/route.ts create mode 100644 src/app/api/medications/categories/route.ts create mode 100644 src/app/medications/__tests__/category-filter.test.tsx create mode 100644 src/components/medications/medication-categories-sheet.tsx create mode 100644 src/components/medications/medication-category-add-row.tsx create mode 100644 src/components/medications/use-medication-categories.ts create mode 100644 src/lib/__tests__/medication-category.test.ts create mode 100644 src/lib/medications/__tests__/category-filter.test.ts create mode 100644 src/lib/medications/category-filter.ts create mode 100644 tests/integration/medication-custom-categories.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cf0c25eb9..ab0c02b1a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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:` 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:`, the category's key." + required: true + description: "`custom:`, 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:`, the category's key." + required: true + description: "`custom:`, 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:`, 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:`, 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:`); 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:`, 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:`); 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:`, 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: diff --git a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql index d649406d1..23d971b47 100644 --- a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql +++ b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql @@ -18,3 +18,25 @@ CREATE TABLE IF NOT EXISTS "medication_categories" ( CREATE INDEX IF NOT EXISTS "medication_categories_category_idx" ON "medication_categories"("category"); + +-- Custom medication categories (#1041): the person's own labels, shown next +-- to the built-in ones. A medication filed under one carries its `key` in +-- `medication_categories.category`. Idempotent. +CREATE TABLE IF NOT EXISTS "medication_category_labels" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label_encrypted" BYTEA NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "medication_category_labels_pkey" PRIMARY KEY ("id"), + CONSTRAINT "medication_category_labels_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS "medication_category_labels_key_key" + ON "medication_category_labels"("key"); +CREATE INDEX IF NOT EXISTS "medication_category_labels_user_id_sort_order_idx" + ON "medication_category_labels"("user_id", "sort_order"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c7cd9569c..68d977d82 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -720,6 +720,7 @@ model User { medications Medication[] intakeEvents MedicationIntakeEvent[] medicationPauseEras MedicationPauseEra[] + medicationCategoryLabels MedicationCategoryLabel[] apiTokens ApiToken[] // v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted // authorization for an MCP client, audience-bound to health:read/health:write. @@ -2832,6 +2833,34 @@ model MedicationCategoryAssignment { @@map("medication_categories") } +/// v1.40 (#1041) — a medication category the person named themselves, shown +/// next to the built-in ones. A medication filed under it carries the row's +/// `key` (`custom:`) in `MedicationCategoryAssignment.category`; the +/// list and detail DTOs publish the decrypted label as `categoryLabel`. +/// Deleting a label moves its medications to OTHER in the same transaction, +/// so the app never writes a dangling key; one a restore leaves behind reads +/// as OTHER. Capped at 20 per account. +model MedicationCategoryLabel { + id String @id @default(cuid()) + userId String @map("user_id") + /// `custom:`, the handle the category column, the backup and the + /// iPhone app use. Never reused. + key String @unique + /// The person's own label, AES-256-GCM at rest. Never logged or audited. + labelEncrypted Bytes @map("label_encrypted") + /// Position in the picker and the filter row, ascending. + sortOrder Int @default(0) @map("sort_order") + /// false = hidden from the picker; medications filed under it keep it. + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, sortOrder]) + @@map("medication_category_labels") +} + /// v1.28 — the user's explicit override of a medication's efficacy target, /// the ONE thing the "Wirkung" view persists. Class→outcome resolution /// (ATC-prefix → name inference) is computed each read from the closed diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index a814f2365..4dfee834f 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -373,6 +373,15 @@ async function main() { prisma.cycleSymptom, ), ); + // v1.40 (#1041) — custom medication categories. A Bytes column, unlike the + // three String label columns above. + results.push( + await rotateBytesColumn( + "MedicationCategoryLabel", + "labelEncrypted", + prisma.medicationCategoryLabel, + ), + ); // ───── NotificationChannel."config" (encrypted JSON) ───── // Channel config (Telegram chat id, ntfy topic, etc.). Skipping these on diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 49b4bf75f..03fe54a8d 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -467,6 +467,14 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "permanent", confirm: ["AlertDialog"], }, + { + file: "components/medications/use-medication-categories.ts", + destroys: + "one of the person's own medication categories; the medications filed under it move to Other, they are not deleted", + triggers: ["components/medications/medication-categories-sheet.tsx"], + recovery: "permanent", + confirm: ["AlertDialog"], + }, { file: "components/medications/use-medication-intake.ts", destroys: "an intake event recorded seconds ago", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 9a5e17cd0..95c04a524 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1023,6 +1023,10 @@ const DELEGABLE_ROUTES: Record = { domain: "medications", why: "The record's medication cabinet. The cached list projection keys on the same resolved id it scopes to, so the owner's cell holds the owner's data. Returns no ingest endpoint and no token count — the route that does is refused.", }, + "app/api/medications/categories/route.ts": { + domain: "medications", + why: "The record's own medication category vocabulary, read and added to from the wizard. The labels and the per-category counts are scoped to the resolved id; renaming and deleting live in the owner-only `[key]` sibling.", + }, "app/api/medications/[id]/route.ts": { domain: "medications", why: "One medication of the record, fetch-then-guard against the resolved user before anything is serialised.", @@ -1646,6 +1650,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C1"], why: "Adding to the record's own symptom vocabulary. C1.", }, + "app/api/medications/categories/route.ts": { + domain: "medications", + conditions: ["C1"], + why: "Adding to the record's own medication category vocabulary, the cycle symptom precedent. C1.", + }, "app/api/medications/[id]/route.ts": { domain: "medications", conditions: ["C4", "C7"], @@ -1960,8 +1969,12 @@ const ACTOR_ROUTES: Record = { * v1.39.2 -- the body-site view adds one: a read on the record list in the * visits' domain, with conditions joined only under an illness grant. * 231 -> 232. + * + * v1.40 -- the custom medication categories add two: the vocabulary read on + * the record list and its create on the manage literal, the cycle symptom + * precedent. Rename and delete stay owner-only. 232 -> 234. */ -const FROZEN_ENTRY_COUNT = 232; +const FROZEN_ENTRY_COUNT = 234; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2722,7 +2735,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(78); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 383bf15d3..8ddf90e93 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -267,6 +267,10 @@ const PINNED_AFFORDANCES: Record< "src/components/medications/sections/inventory-section.tsx": { "toast.success": 1, }, + // v1.40 — the delete names how many medications moved to Other. + "src/components/medications/medication-categories-sheet.tsx": { + "toast.success": 1, + }, "src/components/medications/sections/notifications-section.tsx": { "toast.success": 1, }, diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 34bfae2db..6206614c8 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(78); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 3ffce0401..2e9758306 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -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 ).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 }, }); } diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 6309dfb22..da41187b3 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -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, diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 7fb97d866..3bdc698f4 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -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", diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index dbae9fc1a..ad3e6c63b 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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, }); }, ); diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 888ef60c7..6f58b809e 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/medications/categories/[key]/route.ts b/src/app/api/medications/categories/[key]/route.ts new file mode 100644 index 000000000..85974a8bc --- /dev/null +++ b/src/app/api/medications/categories/[key]/route.ts @@ -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 { + 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 }); + }, +); diff --git a/src/app/api/medications/categories/route.ts b/src/app/api/medications/categories/route.ts new file mode 100644 index 000000000..313012672 --- /dev/null +++ b/src/app/api/medications/categories/route.ts @@ -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:` 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 { + 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, + ); +} diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 0b148d2e7..8f93617a0 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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 { 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 { 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 { 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 { ), ), category: normalizedCategory, + categoryLabel, }, 201, ); diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index d66708448..99aa76753 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -24,6 +24,7 @@ import { Glp1SubstanceCurveSummary } from "@/components/insights/glp1-substance- import { SubPageShell } from "@/components/insights/sub-page-shell"; import { TileHeader } from "@/components/insights/tile-header"; import { apiGet } from "@/lib/api/api-fetch"; +import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; /** * v1.4.25 W4 — `/insights/medications`. @@ -44,7 +45,10 @@ interface MedicationEntry { id: string; name: string; dose: string; - category: "BLOOD_PRESSURE" | "VITAMIN" | "OTHER"; + /** A built-in category value or `custom:` (v1.40). */ + category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; compliance7: number; compliance30: number; streak: number; @@ -234,12 +238,11 @@ export default function InsightsMedikamentePage() { // card carried (7d/30d bars, taken/skipped/missed, heatmap, // per-med assistant sentence) stays — only the vertical rhythm // tightens. - const categoryLabel = - med.category === "BLOOD_PRESSURE" - ? t("medications.categoryBloodPressure") - : med.category === "VITAMIN" - ? t("medications.categoryVitamin") - : t("medications.categoryOther"); + const categoryLabel = getMedicationCategoryLabel( + med.category, + t, + med.categoryLabel, + ); return ( diff --git a/src/app/medications/__tests__/category-filter.test.tsx b/src/app/medications/__tests__/category-filter.test.tsx new file mode 100644 index 000000000..45c7785cb --- /dev/null +++ b/src/app/medications/__tests__/category-filter.test.tsx @@ -0,0 +1,140 @@ +/** + * v1.40 (#1041) — the category filter on /medications and the custom + * category badge. The filter appears once the list spans two categories + * (or the record has categories of its own), offers each category in use, + * and the card badge shows a custom category's own label. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + useSearchParams: () => new URLSearchParams(""), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + isLoading: false, + user: { timezone: "Europe/Berlin" }, + }), +})); + +// The create wizard and the dialogs are closed in every assertion here; +// mock them away so the page render stays scoped to the list surface. +vi.mock("@/components/medications/wizard/medication-wizard-dialog", () => ({ + MedicationWizardDialog: () => null, +})); +vi.mock("@/components/medications/log-intake-dialog", () => ({ + LogIntakeDialog: () => null, +})); + +// v1.30.x — `@/app/medications/page` is now an async RSC prefetch wrapper; the +// interactive surface this test renders is the client leaf it wraps. +import MedicationsPage from "@/app/medications/page-client"; +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { MedicationListLayout } from "@/lib/medication-list-layout"; + +const pastWindow = { + windowStart: "01:00", + windowEnd: "02:00", + label: null, + daysOfWeek: null, + dose: null, +}; + +const KEY = "custom:11111111-1111-4111-8111-111111111111"; + +function med(id: string, name: string, category: string, label?: string) { + return { + id, + name, + dose: "5 mg", + category, + categoryLabel: label ?? null, + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + nextDueAt: null, + nextDueOverdue: false, + stockDosesRemaining: null, + schedules: [{ id: `s-${id}`, ...pastWindow }], + }; +} + +function renderPage( + meds: ReturnType[], + customCategories: unknown[] = [], +): string { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + client.setQueryData(queryKeys.medications(), meds); + client.setQueryData(queryKeys.medicationListLayout(), { + version: 1, + view: "cards", + order: [], + } satisfies MedicationListLayout); + client.setQueryData(queryKeys.medicationCategories(), { + categories: customCategories, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-02T12:00:00Z")); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("/medications — category filter", () => { + it("shows the filter once the list spans two categories, with the custom label on the card", () => { + const html = renderPage([ + med("m1", "Levothyroxine", "THYROID"), + med("m2", "Tamiflu", KEY, "Travel kit"), + ]); + expect(html).toContain('data-slot="filter-bar"'); + expect(html).toContain('data-slot="filter-bar-pill"'); + // The card badge reads the person's own label, not "Other". + expect(html).toContain("Travel kit"); + }); + + it("leaves the filter out when every medication shares one category and none are custom", () => { + const html = renderPage([ + med("m1", "Levothyroxine", "THYROID"), + med("m2", "Liothyronine", "THYROID"), + ]); + expect(html).not.toContain('data-slot="filter-bar"'); + }); + + it("shows the filter, with its manage entry, when the record has categories of its own", () => { + const html = renderPage( + [med("m1", "Levothyroxine", "THYROID")], + [ + { + key: KEY, + label: "Travel kit", + sortOrder: 0, + isActive: true, + medicationCount: 0, + }, + ], + ); + expect(html).toContain('data-slot="filter-bar"'); + expect(html).toContain('data-slot="medication-categories-manage"'); + }); +}); diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 23df5de21..d9331fdcc 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -1,7 +1,7 @@ "use client"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -33,6 +33,15 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { EmptyState } from "@/components/ui/empty-state"; +import { FilterBar, FilterBarSelect } from "@/components/ui/filter-bar"; +import { MedicationCategoriesSheet } from "@/components/medications/medication-categories-sheet"; +import { useMedicationCategories } from "@/components/medications/use-medication-categories"; +import { + ALL_MEDICATION_CATEGORIES, + effectiveCategoryFilter as resolveCategoryFilter, + filterMedicationsByCategory, + medicationCategoryOptions, +} from "@/lib/medications/category-filter"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -74,6 +83,8 @@ interface Medication { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; /** v1.4.25 W4d — Prisma treatment class (GENERIC | GLP1). */ treatmentClass?: string; /** v1.4.25 W4d — doses per pen/vial for inventory math. */ @@ -169,6 +180,63 @@ function MedicationCardSkeleton() { ); } +/** + * v1.40 (#1041) — the category filter is remembered for the browser session + * only, by design: no preference column, and a new visit starts on All. + */ +const CATEGORY_FILTER_STORAGE_KEY = "healthlog.medications.categoryFilter"; +const ALL_CATEGORIES = ALL_MEDICATION_CATEGORIES; + +const CATEGORY_FILTER_EVENT = "healthlog:medication-category-filter"; + +function readStoredCategoryFilter(): string { + try { + return ( + window.sessionStorage.getItem(CATEGORY_FILTER_STORAGE_KEY) ?? + ALL_CATEGORIES + ); + } catch { + return ALL_CATEGORIES; + } +} + +function storeCategoryFilter(value: string) { + try { + if (value === ALL_CATEGORIES) { + window.sessionStorage.removeItem(CATEGORY_FILTER_STORAGE_KEY); + } else { + window.sessionStorage.setItem(CATEGORY_FILTER_STORAGE_KEY, value); + } + } catch { + // Storage blocked: the pick still applies until the page unmounts. + } + window.dispatchEvent(new Event(CATEGORY_FILTER_EVENT)); +} + +function subscribeCategoryFilter(onChange: () => void): () => void { + window.addEventListener(CATEGORY_FILTER_EVENT, onChange); + return () => window.removeEventListener(CATEGORY_FILTER_EVENT, onChange); +} + +/** + * The stored pick, read through `useSyncExternalStore` so the server render + * (and the first client render) is All and the stored pick follows without a + * hydration mismatch. Blocked storage degrades to a per-view filter. + */ +function useCategoryFilter(): [string, (value: string) => void] { + const [fallback, setFallback] = useState(null); + const stored = useSyncExternalStore( + subscribeCategoryFilter, + readStoredCategoryFilter, + () => ALL_CATEGORIES, + ); + const set = useCallback((value: string) => { + setFallback(value); + storeCategoryFilter(value); + }, []); + return [fallback ?? stored, set]; +} + export default function MedicationsPageClient() { const { user, isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); @@ -213,6 +281,15 @@ export default function MedicationsPageClient() { // v1.16.11 (#316) — "Alle fälligen einnehmen" confirm dialog. The header // button earns its slot only while ≥ 2 medications are currently due. const [takeAllOpen, setTakeAllOpen] = useState(false); + // v1.40 (#1041) — the category filter and the manage sheet it opens. + const [categoryFilter, setCategoryFilter] = useCategoryFilter(); + const [manageCategoriesOpen, setManageCategoriesOpen] = useState(false); + // Renaming, hiding and deleting a category stay with the owner (the edit + // route is owner-only, like the cycle symptom vocabulary's). + const canManageCategories = !inSharedRecord; + const customCategories = useMedicationCategories({ + enabled: isAuthenticated && medicationsEnabled, + }); // v1.16.10 — the persisted list presentation: cards vs table plus the // manual medication order, server-side per user @@ -382,6 +459,19 @@ export default function MedicationsPageClient() { // Defensive against stale service-worker responses or any future API // shape change: only filter when we actually have an array. const medsArray = Array.isArray(medications) ? medications : []; + // v1.40 (#1041) — the categories in use, each once, labelled the way the + // card badge labels it. The filter offers only these, so a pick can never + // empty the list. + const categoriesInUse = medicationCategoryOptions(medsArray, t); + const effectiveCategoryFilter = resolveCategoryFilter( + categoryFilter, + categoriesInUse, + ); + const hasCustomCategories = (customCategories.data ?? []).length > 0; + const showCategoryFilter = + medsArray.length > 0 && + (categoriesInUse.length >= 2 || hasCustomCategories); + // v1.16.10 — the user-defined manual order applies to BOTH views // (cards and table). Medications not in the saved order keep the // alphabetical default, appended after the ordered block. @@ -394,6 +484,16 @@ export default function MedicationsPageClient() { layout.order, ); const tableView = layout.view === "table"; + // The filter narrows what the list shows; the due set and the log-intake + // dialog below keep reading every medication. + const shownActiveMeds = filterMedicationsByCategory( + activeMeds, + effectiveCategoryFilter, + ); + const shownInactiveMeds = filterMedicationsByCategory( + inactiveMeds, + effectiveCategoryFilter, + ); // v1.16.11 (#316) — the currently-due set, derived from the list payload // the page already holds via the SAME pipeline a card's pill runs (band @@ -493,6 +593,34 @@ export default function MedicationsPageClient() { } /> + {showCategoryFilter && !isLoading && !isError && ( + setCategoryFilter(ALL_CATEGORIES)} + > + + {canManageCategories && ( + + )} + + )} + {isLoading || isLayoutLoading ? ( // v1.11.3 C4 — skeletons instead of a bare centred spinner so the // page reserves the loaded layout and does not jump when the @@ -540,17 +668,17 @@ export default function MedicationsPageClient() { // gating, same mutation hooks as the cards; one row per // medication, inactive rows pinned after the active block. ) : (
{/* Active medications */} - {activeMeds.length > 0 && ( + {shownActiveMeds.length > 0 && (
- {activeMeds.map((med) => + {shownActiveMeds.map((med) => // v1.16.11 — an as-needed medication always renders the // generic card: the GLP-1 variant is built around the // rolling injection cadence an as-needed med doesn't have. @@ -577,13 +705,13 @@ export default function MedicationsPageClient() { )} {/* Inactive medications */} - {inactiveMeds.length > 0 && ( + {shownInactiveMeds.length > 0 && (

- {t("common.inactive")} ({inactiveMeds.length}) + {t("common.inactive")} ({shownInactiveMeds.length})

- {inactiveMeds.map((med) => + {shownInactiveMeds.map((med) => med.treatmentClass === "GLP1" && !med.asNeeded ? ( )} + {manageCategoriesOpen && canManageCategories && ( + + )} + {logIntakeOpen && ( ); - const categoryLabel = getMedicationCategoryLabel(medication.category, t); + const categoryLabel = getMedicationCategoryLabel( + medication.category, + t, + medication.categoryLabel, + ); // The upcoming-injection line value. The card owns this VALUE content — // the liked relative-day phrasing ("Samstag 13.7. (in 7 Tagen)") — while diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 577e171a1..edd97d6bb 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -63,6 +63,8 @@ interface Medication { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; /** * v1.4.25 W4d — Prisma treatment-class discriminator. When set to * "GLP1" the parent should render the {@link Glp1MedicationCard} @@ -254,7 +256,11 @@ export function MedicationCard({ // background/border tint. Defaults to "upcoming" (calm) when the display // block is absent (older mocks). const doseStatus = display?.currentDose.status ?? "upcoming"; - const categoryLabel = getMedicationCategoryLabel(medication.category, t); + const categoryLabel = getMedicationCategoryLabel( + medication.category, + t, + medication.categoryLabel, + ); const sortedSchedules = [...medication.schedules].sort( (a, b) => a.windowStart.localeCompare(b.windowStart) || diff --git a/src/components/medications/medication-categories-sheet.tsx b/src/components/medications/medication-categories-sheet.tsx new file mode 100644 index 000000000..4e438ca63 --- /dev/null +++ b/src/components/medications/medication-categories-sheet.tsx @@ -0,0 +1,285 @@ +"use client"; + +/** + * v1.40 (#1041) — manage the person's own medication categories: rename, + * hide from the picker, delete, add. Opens from the category filter on the + * medications list. Rows follow the mood manager's archived-tag row anatomy; + * the delete confirmation names how many medications move to Other. + */ +import { Eye, EyeOff, Loader2, Pencil, Tag, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; + +import { AddMedicationCategoryRow } from "./medication-category-add-row"; +import { + type MedicationCategoryLabelDTO, + useDeleteMedicationCategory, + useMedicationCategories, + useUpdateMedicationCategory, +} from "./use-medication-categories"; + +function CategoryRow({ + category, + onDelete, +}: { + category: MedicationCategoryLabelDTO; + onDelete: (category: MedicationCategoryLabelDTO) => void; +}) { + const { t } = useTranslations(); + const update = useUpdateMedicationCategory(); + const [editing, setEditing] = useState(false); + const [label, setLabel] = useState(category.label); + + async function rename() { + const trimmed = label.trim(); + if (!trimmed || trimmed === category.label) { + setEditing(false); + return; + } + try { + await update.mutateAsync({ key: category.key, label: trimmed }); + setEditing(false); + } catch { + toast.error(t("medications.category.custom.saveFailed")); + } + } + + async function toggleHidden() { + try { + await update.mutateAsync({ + key: category.key, + isActive: !category.isActive, + }); + } catch { + toast.error(t("medications.category.custom.saveFailed")); + } + } + + if (editing) { + return ( +
+ setLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void rename(); + } + }} + /> +
+ + +
+
+ ); + } + + return ( +
+
+ ); +} + +export function MedicationCategoriesSheet({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + const categories = useMedicationCategories(); + const remove = useDeleteMedicationCategory(); + const [pendingDelete, setPendingDelete] = + useState(null); + + async function confirmDelete(category: MedicationCategoryLabelDTO) { + try { + const result = await remove.mutateAsync(category.key); + toast.success( + t("medications.category.custom.movedToOther", { + count: String(result.movedCount), + }), + ); + } catch { + toast.error(t("medications.category.custom.saveFailed")); + } + } + + return ( + +
+ {categories.isError ? ( + void categories.refetch()} /> + ) : ( + (categories.data ?? []).map((category) => ( + + )) + )} + +

+ {t("medications.category.custom.bpGateNote")} +

+
+ + { + if (!next) setPendingDelete(null); + }} + > + + + + {t("medications.category.custom.deleteTitle")} + + + {t("medications.category.custom.deleteBody", { + count: String(pendingDelete?.medicationCount ?? 0), + })} + + + + {t("common.cancel")} + { + const category = pendingDelete; + setPendingDelete(null); + if (category) void confirmDelete(category); + }} + > + {t("medications.category.custom.delete")} + + + + +
+ ); +} diff --git a/src/components/medications/medication-category-add-row.tsx b/src/components/medications/medication-category-add-row.tsx new file mode 100644 index 000000000..1fad4ed6d --- /dev/null +++ b/src/components/medications/medication-category-add-row.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Loader2, Plus } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useTranslations } from "@/lib/i18n/context"; + +import { + isCategoryLimitError, + useCreateMedicationCategory, +} from "./use-medication-categories"; + +/** + * v1.40 (#1041) — the inline "New category" row: a dashed row (the cycle + * log's add-symptom pattern) that opens into a label field in place, so + * creating a category never leaves the wizard or the manage sheet. The + * caller hears the new key as soon as the category exists. + */ +export function AddMedicationCategoryRow({ + onCreated, +}: { + onCreated?: (key: string) => void; +}) { + const { t } = useTranslations(); + const create = useCreateMedicationCategory(); + const [open, setOpen] = useState(false); + const [label, setLabel] = useState(""); + + function close() { + setOpen(false); + setLabel(""); + create.reset(); + } + + async function submit() { + const trimmed = label.trim(); + if (!trimmed) return; + try { + const created = await create.mutateAsync({ label: trimmed }); + onCreated?.(created.key); + close(); + } catch { + // `create.error` drives the inline message below. + } + } + + if (!open) { + return ( + + ); + } + + return ( +
+ setLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + close(); + } + }} + /> + {create.error ? ( +

+ {isCategoryLimitError(create.error) + ? t("medications.category.custom.limitReached") + : t("medications.category.custom.saveFailed")} +

+ ) : null} +
+ + +
+
+ ); +} diff --git a/src/components/medications/use-medication-categories.ts b/src/components/medications/use-medication-categories.ts new file mode 100644 index 000000000..57a197ca9 --- /dev/null +++ b/src/components/medications/use-medication-categories.ts @@ -0,0 +1,97 @@ +"use client"; + +/** + * v1.40 (#1041) — the record's own medication categories: read, create, + * rename / hide, delete. A rename or a delete changes what every card and the + * insights medication tiles show (a delete moves medications to Other), so + * both refetch the medication reads and the insights reads. Neither touches a + * dose, so the daily reads the intake bundle refreshes stay as they are. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + ApiError, + apiDelete, + apiGet, + apiPatch, + apiPost, +} from "@/lib/api/api-fetch"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; + +const CATEGORY_DEPENDENT_KEYS = [ + queryKeys.medications(), + queryKeys.insightsRoot(), +] as const; + +export interface MedicationCategoryLabelDTO { + key: string; + label: string; + sortOrder: number; + isActive: boolean; + medicationCount: number; +} + +/** The `errorCode` the create POST answers when the per-account cap is hit. */ +export const MEDICATION_CATEGORY_LIMIT_ERROR_CODE = + "medications.category.limitReached"; + +/** Whether a failed create hit the cap rather than anything transient. */ +export function isCategoryLimitError(error: unknown): boolean { + return ( + error instanceof ApiError && + error.meta?.errorCode === MEDICATION_CATEGORY_LIMIT_ERROR_CODE + ); +} + +export function useMedicationCategories(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: queryKeys.medicationCategories(), + queryFn: () => + apiGet<{ categories: MedicationCategoryLabelDTO[] }>( + "/api/medications/categories", + ), + staleTime: 5 * 60_000, + select: (data) => data.categories, + }); +} + +export function useCreateMedicationCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { label: string }) => + apiPost("/api/medications/categories", input), + onSuccess: () => + qc.invalidateQueries({ queryKey: queryKeys.medicationCategories() }), + }); +} + +export function useUpdateMedicationCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { + key: string; + label?: string; + isActive?: boolean; + sortOrder?: number; + }) => { + const { key, ...body } = input; + return apiPatch( + `/api/medications/categories/${encodeURIComponent(key)}`, + body, + ); + }, + onSuccess: () => invalidateKeys(qc, CATEGORY_DEPENDENT_KEYS), + }); +} + +export function useDeleteMedicationCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (key: string) => + apiDelete<{ key: string; movedCount: number }>( + `/api/medications/categories/${encodeURIComponent(key)}`, + ), + onSuccess: () => invalidateKeys(qc, CATEGORY_DEPENDENT_KEYS), + }); +} diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 9de116879..f8edcbfa3 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -1330,3 +1330,85 @@ describe("category on edit (v1.39.4)", () => { expect(body.category).toBe("SKIN"); }); }); + +describe("custom categories (v1.40, #1041)", () => { + const KEY = "custom:11111111-1111-4111-8111-111111111111"; + const OTHER_KEY = "custom:22222222-2222-4222-8222-222222222222"; + + function hydrate(category: string, treatmentClass = "GENERIC") { + return hydrateWizardPayload({ + id: "m1", + name: "Tamiflu", + dose: "75 mg", + category, + treatmentClass, + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [ + { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + }, + ], + }); + } + + it("hydrates a custom category onto the Other row with its key", () => { + const payload = hydrate(KEY); + expect(payload.treatmentRow).toBe("other"); + expect(payload.customCategoryKey).toBe(KEY); + }); + + it("keeps the custom category on an edit that leaves it alone", () => { + const body = buildCreateBody({ ...hydrate(KEY), name: "Renamed" }, "edit"); + expect("category" in body).toBe(false); + }); + + it("sends the new key when the person picks another custom category", () => { + const body = buildCreateBody( + { ...hydrate(KEY), customCategoryKey: OTHER_KEY }, + "edit", + ); + expect(body.category).toBe(OTHER_KEY); + expect(body.treatmentClass).toBe("GENERIC"); + }); + + it("sends the built-in value when the person moves it back to a built-in row", () => { + const body = buildCreateBody( + { ...hydrate(KEY), treatmentRow: "vitamin", customCategoryKey: null }, + "edit", + ); + expect(body.category).toBe("VITAMIN"); + }); + + it("sends the built-in Other when the person picks the Other row itself", () => { + const body = buildCreateBody( + { ...hydrate(KEY), customCategoryKey: null }, + "edit", + ); + expect(body.category).toBe("OTHER"); + }); + + it("files a new medication under a custom category on create", () => { + const body = buildCreateBody( + { + ...hydrate("VITAMIN"), + treatmentRow: "other", + customCategoryKey: KEY, + }, + "create", + ); + expect(body.category).toBe(KEY); + expect(body.treatmentClass).toBe("GENERIC"); + }); + + it("keeps a GLP-1 medication on the GLP-1 row whatever its category", () => { + const payload = hydrate(KEY, "GLP1"); + expect(payload.treatmentRow).toBe("glp1"); + expect(payload.customCategoryKey).toBeNull(); + }); +}); diff --git a/src/components/medications/wizard/steps/step2-class.tsx b/src/components/medications/wizard/steps/step2-class.tsx index 1337cbbbf..f066cbb88 100644 --- a/src/components/medications/wizard/steps/step2-class.tsx +++ b/src/components/medications/wizard/steps/step2-class.tsx @@ -21,8 +21,12 @@ import { } from "lucide-react"; import type { ComponentType } from "react"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; +import { AddMedicationCategoryRow } from "../../medication-category-add-row"; +import { useMedicationCategories } from "../../use-medication-categories"; + import { type WizardTreatmentRow, WIZARD_TREATMENT_ROWS, @@ -48,8 +52,60 @@ const ROW_ICONS: Record> = { other: Tag as unknown as LucideIcon, }; +/** One selectable row: the same anatomy for built-in and custom categories. */ +function ClassRow({ + value, + label, + Icon, + selected, + onSelect, + slot, +}: { + value: string; + label: string; + Icon: ComponentType; + selected: boolean; + onSelect: () => void; + slot: "wizard-class-row" | "wizard-custom-class-row"; +}) { + return ( + + ); +} + export function Step2Class({ payload, applyPartial }: StepProps) { const { t } = useTranslations(); + const categories = useMedicationCategories(); + // Creating a category is admitted at MANAGE in a shared record. + const { canManageDomain } = useRecordCapabilities(); + const customKey = payload.customCategoryKey ?? null; + // Hidden categories leave the picker, except the one this medication is + // already filed under: an edit must be able to show what it has. + const customRows = (categories.data ?? []).filter( + (c) => c.isActive || c.key === customKey, + ); return (
- {WIZARD_TREATMENT_ROWS.map((row) => { - const Icon = ROW_ICONS[row]; - const selected = payload.treatmentRow === row; - const label = t(`medications.wizard.classRow.${row}`); - return ( - - ); - })} + {WIZARD_TREATMENT_ROWS.map((row) => ( + + applyPartial( + // The GLP-1 row is always injected; nudge the + // delivery form so the common case needs no extra + // tap. The user can still flip it on Step 3. + row === "glp1" + ? { + treatmentRow: row, + customCategoryKey: null, + deliveryForm: "INJECTION", + } + : { treatmentRow: row, customCategoryKey: null }, + ) + } + /> + ))} + {customRows.map((c) => ( + + applyPartial({ treatmentRow: "other", customCategoryKey: c.key }) + } + /> + ))} + {canManageDomain("medications") && ( + + applyPartial({ treatmentRow: "other", customCategoryKey: key }) + } + /> + )} +

+ {t("medications.category.custom.bpGateNote")} +

); } diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 498e7c685..5c08230d1 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -192,6 +192,24 @@ export function rowFromTreatment( } } +/** + * v1.40 (#1041) — the custom category an edit hydrates to. A GLP-1 + * medication is the GLP-1 row whatever its category says, as above. + */ +function customCategoryFields( + treatmentClass: string | undefined, + category: string | undefined, +): { + customCategoryKey: string | null; + initialCustomCategoryKey: string | null; +} { + const key = + treatmentClass !== "GLP1" && category?.startsWith("custom:") + ? category + : null; + return { customCategoryKey: key, initialCustomCategoryKey: key }; +} + // ──────────────────────────────────────────────────────────────────── // Wizard payload + per-schedule draft // ──────────────────────────────────────────────────────────────────── @@ -257,6 +275,15 @@ export interface WizardPayload { * Absent on create. */ initialTreatmentRow?: WizardTreatmentRow | null; + /** + * v1.40 (#1041) — one of the person's own categories, picked from the + * custom rows under the built-in ones. Set together with `treatmentRow: + * "other"` (the treatment class a custom category files under); null when + * a built-in row is picked. + */ + customCategoryKey?: string | null; + /** v1.40 — the custom category the edit hydrated to, for the same reason. */ + initialCustomCategoryKey?: string | null; /** * v1.6.0 — route of administration. Drives the injection-site * rotation preview (shown when `INJECTION`) and, paired with @@ -628,8 +655,12 @@ export function firstInvalidIndex( export interface CreateMedicationBody { name: string; dose: string; - /** Omitted on an edit that left the category row untouched (v1.39.4). */ - category?: MedicationCategoryValue; + /** + * Omitted on an edit that left the category row untouched (v1.39.4). A + * built-in value or, since v1.40, the key of one of the person's own + * categories. + */ + category?: MedicationCategoryValue | `custom:${string}`; treatmentClass?: MedicationTreatmentClass; /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ deliveryForm: MedicationDeliveryForm; @@ -843,16 +874,21 @@ export function buildCreateBody( // v1.39.4 — an edit that did not touch the category row leaves the stored // category (and treatment class) alone rather than re-deriving them from // the row, which cannot represent every stored value. + const customCategoryKey = committed.customCategoryKey ?? null; const keepStoredCategory = forMode === "edit" && committed.initialTreatmentRow !== undefined && - committed.treatmentRow === committed.initialTreatmentRow; + committed.treatmentRow === committed.initialTreatmentRow && + customCategoryKey === (committed.initialCustomCategoryKey ?? null); const body: CreateMedicationBody = { name: committed.name.trim(), dose, ...(!keepStoredCategory && { - category: mapping.category, - treatmentClass: mapping.treatmentClass, + // v1.40 — a custom row files under the person's own category with the + // generic treatment class, like the built-in Other row. + category: (customCategoryKey ?? + mapping.category) as CreateMedicationBody["category"], + treatmentClass: customCategoryKey ? "GENERIC" : mapping.treatmentClass, }), deliveryForm: committed.deliveryForm, ...(Number.isFinite(parsedDosesPerUnit) && @@ -1092,6 +1128,8 @@ export interface MedicationPayload { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; treatmentClass?: string; /** v1.6.0 — route of administration. Defaults to ORAL when absent. */ deliveryForm?: string; @@ -1239,6 +1277,7 @@ export function hydrateWizardPayload( initial.treatmentClass, initial.category, ), + ...customCategoryFields(initial.treatmentClass, initial.category), deliveryForm: normaliseDeliveryForm(initial.deliveryForm), dosesPerUnit: typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1 diff --git a/src/lib/__tests__/medication-category.test.ts b/src/lib/__tests__/medication-category.test.ts new file mode 100644 index 000000000..6d49fd106 --- /dev/null +++ b/src/lib/__tests__/medication-category.test.ts @@ -0,0 +1,179 @@ +/** + * v1.40 (#1041) — the one helper that resolves and writes a medication's + * category. Prisma is stubbed; the real-database contract lives in + * `tests/integration/medication-custom-categories.test.ts`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + assignmentFindMany: vi.fn(), + assignmentUpsert: vi.fn(), + assignmentUpdateMany: vi.fn(), + labelFindMany: vi.fn(), + labelFindFirst: vi.fn(), + labelDelete: vi.fn(), + medicationFindUnique: vi.fn(), + annotate: vi.fn(), + order: [] as string[], +})); + +vi.mock("@/lib/db", () => { + const client = { + medicationCategoryAssignment: { + findMany: mocks.assignmentFindMany, + upsert: mocks.assignmentUpsert, + updateMany: mocks.assignmentUpdateMany, + }, + medicationCategoryLabel: { + findMany: mocks.labelFindMany, + findFirst: mocks.labelFindFirst, + delete: mocks.labelDelete, + }, + medication: { findUnique: mocks.medicationFindUnique }, + }; + return { + prisma: { + ...client, + $transaction: async (fn: (tx: typeof client) => unknown) => fn(client), + }, + }; +}); +vi.mock("@/lib/logging/context", () => ({ annotate: mocks.annotate })); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => new TextEncoder().encode(`sealed:${s}`), + decryptFromBytes: (b: Uint8Array) => + new TextDecoder().decode(b).replace(/^sealed:/, ""), +})); + +import { + deleteMedicationCategoryLabel, + isAssignableCategory, + resolveMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; + +const OWN = "custom:11111111-1111-4111-8111-111111111111"; +const FOREIGN = "custom:22222222-2222-4222-8222-222222222222"; +const GONE = "custom:33333333-3333-4333-8333-333333333333"; +const sealed = (s: string) => new TextEncoder().encode(`sealed:${s}`); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.order.length = 0; +}); + +describe("resolveMedicationCategories", () => { + it("resolves built-ins, the owner's own key with its label, and nothing else", async () => { + mocks.assignmentFindMany.mockResolvedValue([ + { medicationId: "m1", category: "THYROID", medication: { userId: "u1" } }, + { medicationId: "m2", category: OWN, medication: { userId: "u1" } }, + { medicationId: "m3", category: FOREIGN, medication: { userId: "u1" } }, + { medicationId: "m4", category: GONE, medication: { userId: "u1" } }, + { + medicationId: "m5", + category: "NOT_A_VALUE", + medication: { userId: "u1" }, + }, + ]); + mocks.labelFindMany.mockResolvedValue([ + { key: OWN, userId: "u1", labelEncrypted: sealed("Travel kit") }, + { key: FOREIGN, userId: "u2", labelEncrypted: sealed("Theirs") }, + ]); + + const result = await resolveMedicationCategories([ + "m1", + "m2", + "m3", + "m4", + "m5", + "m6", + ]); + + expect(result).toEqual({ + m1: { category: "THYROID", categoryLabel: null }, + m2: { category: OWN, categoryLabel: "Travel kit" }, + // Another account's label never resolves, even by its exact key. + m3: { category: "OTHER", categoryLabel: null }, + m4: { category: "OTHER", categoryLabel: null }, + m5: { category: "OTHER", categoryLabel: null }, + // No row at all reads as OTHER. + m6: { category: "OTHER", categoryLabel: null }, + }); + // The two custom keys that resolved to nothing are counted, not thrown. + expect(mocks.annotate).toHaveBeenCalledWith({ + meta: { "medication.category.dangling": 2 }, + }); + }); + + it("skips the label read when no custom key is in play", async () => { + mocks.assignmentFindMany.mockResolvedValue([ + { medicationId: "m1", category: "VITAMIN", medication: { userId: "u1" } }, + ]); + await resolveMedicationCategories(["m1"]); + expect(mocks.labelFindMany).not.toHaveBeenCalled(); + expect(mocks.annotate).not.toHaveBeenCalled(); + }); +}); + +describe("isAssignableCategory / setMedicationCategory", () => { + it("admits built-ins and the owner's own key only", async () => { + mocks.labelFindFirst.mockImplementation( + async ({ where }: { where: { key: string; userId: string } }) => + where.key === OWN && where.userId === "u1" ? { key: OWN } : null, + ); + expect(await isAssignableCategory("u1", "MENTAL_HEALTH")).toBe(true); + expect(await isAssignableCategory("u1", OWN)).toBe(true); + expect(await isAssignableCategory("u1", FOREIGN)).toBe(false); + expect(await isAssignableCategory("u1", "custom")).toBe(false); + expect(await isAssignableCategory("u1", "PIZZA")).toBe(false); + }); + + it("writes OTHER for a key the medication's owner does not hold", async () => { + mocks.medicationFindUnique.mockResolvedValue({ userId: "u1" }); + mocks.labelFindFirst.mockResolvedValue(null); + const written = await setMedicationCategory("m1", FOREIGN); + expect(written).toBe("OTHER"); + expect(mocks.assignmentUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: { medicationId: "m1", category: "OTHER" }, + update: { category: "OTHER" }, + }), + ); + }); + + it("writes the owner's own key", async () => { + mocks.medicationFindUnique.mockResolvedValue({ userId: "u1" }); + mocks.labelFindFirst.mockResolvedValue({ key: OWN }); + expect(await setMedicationCategory("m1", OWN)).toBe(OWN); + }); +}); + +describe("deleteMedicationCategoryLabel", () => { + it("moves the category's medications to OTHER before the label goes", async () => { + mocks.labelFindFirst.mockResolvedValue({ id: "label-1" }); + mocks.assignmentUpdateMany.mockImplementation(async () => { + mocks.order.push("move"); + return { count: 3 }; + }); + mocks.labelDelete.mockImplementation(async () => { + mocks.order.push("delete"); + return {}; + }); + + const result = await deleteMedicationCategoryLabel("u1", OWN); + + expect(result).toEqual({ movedCount: 3 }); + expect(mocks.assignmentUpdateMany).toHaveBeenCalledWith({ + where: { category: OWN, medication: { userId: "u1" } }, + data: { category: "OTHER" }, + }); + expect(mocks.order).toEqual(["move", "delete"]); + }); + + it("answers null and touches nothing for a key that is not the caller's", async () => { + mocks.labelFindFirst.mockResolvedValue(null); + expect(await deleteMedicationCategoryLabel("u1", FOREIGN)).toBeNull(); + expect(mocks.assignmentUpdateMany).not.toHaveBeenCalled(); + expect(mocks.labelDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 67d37b102..6206b7254 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -210,10 +210,11 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ kind: "string", }, - // ───── Custom labels (mood + cycle) ───── + // ───── Custom labels (mood, cycle, medication categories) ───── { model: "MoodTag", field: "labelEncrypted", kind: "string" }, { model: "MoodTagCategory", field: "labelEncrypted", kind: "string" }, { model: "CycleSymptom", field: "labelEncrypted", kind: "string" }, + { model: "MedicationCategoryLabel", field: "labelEncrypted", kind: "bytes" }, // ───── Notification + push secrets ───── { model: "NotificationChannel", field: "config", kind: "string" }, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 60512c4cf..ad8a4f43e 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -73,6 +73,9 @@ export const WIPE_MODELS = [ "MedicationInventoryItem", "MedicationIntakeImportJob", "MedicationComplianceRollup", + // The account's own category names (v1.40, #1041). No child rows; the + // medications that name one go with their own entry. + "MedicationCategoryLabel", // ── Mood ──────────────────────────────────────────────────────────────── // The day context, before the entry it hangs off: it cascades either way, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 0471bf2d9..351b9ff23 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ // through it is proven in tests/integration/backup-round-trip.test.ts. vi.mock("@/lib/medication-category", () => ({ getMedicationCategories: vi.fn(async () => ({})), + readMedicationCategoryLabelsForBackup: vi.fn(async () => []), setMedicationCategory: vi.fn(async () => "OTHER"), })); vi.mock("@/lib/export/paged-measurements", () => ({ diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index d95ed71c8..3a79e66f2 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -35,6 +35,9 @@ const mocks = vi.hoisted(() => ({ // through it is proven in tests/integration/backup-round-trip.test.ts. vi.mock("@/lib/medication-category", () => ({ getMedicationCategories: vi.fn(async () => ({})), + readMedicationCategoryLabelsForBackup: vi.fn(async () => []), + encryptCategoryLabel: vi.fn(() => new Uint8Array()), + mintCustomMedicationCategoryKey: vi.fn(() => "custom:minted"), setMedicationCategory: vi.fn(async () => "OTHER"), })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index a49e803ff..14ebbb35f 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -83,6 +83,9 @@ export const BACKED_UP_MODELS = [ // formats and is written back in the restore transaction. Without it every // restored medication reads as Other. "MedicationCategoryAssignment", + // The categories the person named themselves (v1.40, #1041). A medication + // filed under one carries its key; without the row the key reads as Other. + "MedicationCategoryLabel", // ── Mood ────────────────────────────────────────────────────────────────── "MoodEntry", @@ -310,6 +313,7 @@ export const TWO_ENDED_MODELS = [ "MedicationPauseEra", "MedicationDoseChange", "MedicationCategoryAssignment", + "MedicationCategoryLabel", "MoodEntry", "MoodContext", "MoodEntryTagLink", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index aaec42083..63892829f 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -122,7 +122,10 @@ import { buildAccountSettingsBackupSection, type AccountSettingsBackupSection, } from "@/lib/export/account-settings-backup"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { + getMedicationCategories, + readMedicationCategoryLabelsForBackup, +} from "@/lib/medication-category"; export interface FullBackupCounts extends @@ -970,6 +973,12 @@ export async function buildFullBackupPayload( medications.map((m) => m.id), prisma, ); + // v1.40 (#1041) — the account's own categories, restored before the + // medications that name them by key. Labels travel decrypted so a file + // restores on an instance with a different key; every one is carried, + // hidden ones included, because a medication can still be filed under it. + const customMedicationCategories = + await readMedicationCategoryLabelsForBackup(userId, prisma); // Where each archived era sits in its OWN drug's ordered list. Built once // here rather than per row, and scoped per medication because the supersede @@ -1001,6 +1010,7 @@ export async function buildFullBackupPayload( : null, ...accountSettingsSection, measurements: bulk.measurements, + customMedicationCategories, medications: medications.map((m) => ({ ...(disasterRecovery ? { diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 0dbfb4bd2..f7299abac 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -107,7 +107,11 @@ import { restoreAwardsData } from "@/lib/export/awards-backup"; import { restoreEnvironmentData } from "@/lib/export/environment-backup"; import { restoreEcgData } from "@/lib/export/ecg-backup"; import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at"; -import { setMedicationCategory } from "@/lib/medication-category"; +import { + encryptCategoryLabel, + mintCustomMedicationCategoryKey, + setMedicationCategory, +} from "@/lib/medication-category"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; import { stampSyncReset } from "@/lib/sync/reset"; @@ -1049,6 +1053,34 @@ export async function restoreBackup( // is where an operator has to go to see what the position meant, and // an id would be one the restore never wrote. const unresolvedRevisionLinks: string[] = []; + // v1.40 (#1041) — the account's own medication categories, before + // the medications that name them by key. Delete-then-recreate like + // the medications themselves. A key held by another account on this + // instance (a portable file from elsewhere) is re-minted, and the + // medications below are mapped onto the new key. + await tx.medicationCategoryLabel.deleteMany({ + where: { userId: ownerId }, + }); + const categoryKeyRemap = new Map(); + for (const c of payload.customMedicationCategories) { + const taken = await tx.medicationCategoryLabel.findUnique({ + where: { key: c.key }, + select: { id: true }, + }); + const key = taken ? mintCustomMedicationCategoryKey() : c.key; + if (key !== c.key) categoryKeyRemap.set(c.key, key); + await tx.medicationCategoryLabel.create({ + data: { + userId: ownerId, + key, + labelEncrypted: encryptCategoryLabel(c.label), + sortOrder: c.sortOrder, + isActive: c.isActive, + ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + }, + }); + } + let medicationIndex = 0; reportSection("medications"); const restoreStartedAt = new Date(); @@ -1262,7 +1294,11 @@ export async function restoreBackup( // medication id, so it is written after the row exists and inside the // same transaction. OTHER is what a missing row already reads as. if (m.category && m.category !== "OTHER") { - await setMedicationCategory(created.id, m.category, tx); + await setMedicationCategory( + created.id, + categoryKeyRemap.get(m.category) ?? m.category, + tx, + ); } // ── The archived schedule eras, in two passes ──────────────── diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts index eae946c0a..72794edcf 100644 --- a/src/lib/mcp/__tests__/resources.test.ts +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -10,6 +10,9 @@ vi.mock("@/lib/db", () => ({ }, })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/medication-category", () => ({ + resolveMedicationCategories: vi.fn(), +})); vi.mock("@/lib/doctor-report-data", () => ({ collectDoctorReportData: vi.fn(), })); @@ -27,6 +30,7 @@ import { prisma } from "@/lib/db"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; import { metricStatusDiscoveryRows } from "@/lib/mcp/rich-reads"; +import { resolveMedicationCategories } from "@/lib/medication-category"; import type { McpAuthContext } from "../auth"; const CTX: McpAuthContext = { @@ -104,8 +108,15 @@ describe("healthlog://profile", () => { describe("healthlog://medications", () => { it("lists medications + schedules scoped to the session user", async () => { + vi.mocked(resolveMedicationCategories).mockResolvedValue({ + "med-a": { + category: "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", + categoryLabel: "Travel kit", + }, + }); vi.mocked(prisma.medication.findMany).mockResolvedValue([ { + id: "med-a", name: "Med A", dose: "10mg", treatmentClass: "GENERIC", @@ -139,6 +150,14 @@ describe("healthlog://medications", () => { expect(result.present).toBe(true); expect(result.count).toBe(1); expect(Array.isArray(result.medications)).toBe(true); + // v1.40 (#1041) — the custom category rides with its label, fenced as + // the person's own text. + expect( + (result.medications as Array>)[0], + ).toMatchObject({ + category: "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", + categoryLabel: "<<>>Travel kit<<>>", + }); }); it("returns { present: false } when no medications are tracked", async () => { diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index f0bf9d68c..357894bab 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -26,6 +26,8 @@ * external-assistant boundary. */ import { prisma } from "@/lib/db"; +import { fenceUserText } from "@/lib/ai/coach/data-fence"; +import { resolveMedicationCategories } from "@/lib/medication-category"; import { annotate } from "@/lib/logging/context"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; @@ -130,6 +132,11 @@ function ageYears(dateOfBirth: Date | null): number | null { */ const MODULE_DISABLED = { present: false, reason: "module_disabled" } as const; +/** A person-written label, fenced as user text; null stays null. */ +function fenceOptional(text: string | null | undefined): string | null { + return text ? fenceUserText(text) : null; +} + function firstVar(value: string | string[] | undefined): string { if (Array.isArray(value)) return value[0] ?? ""; return value ?? ""; @@ -193,6 +200,9 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ meta: { resource: "medications", present: medications.length > 0 }, }); if (medications.length === 0) return { present: false, count: 0 }; + const categories = await resolveMedicationCategories( + medications.map((med) => med.id), + ); return { present: true, count: medications.length, @@ -200,6 +210,11 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ name: med.name, dose: med.dose, treatmentClass: med.treatmentClass, + // v1.40 (#1041) — the clinical category: a built-in value or + // `custom:`, with the person's own label (their text, fenced) + // for a custom one. + category: categories[med.id]?.category ?? "OTHER", + categoryLabel: fenceOptional(categories[med.id]?.categoryLabel), asNeeded: med.asNeeded, // v1.39.1 (#1033) — false: kept as a record, nothing is due and // no adherence applies; the schedules below are information. diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index ad4cd1c8b..f6240db5a 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -1,11 +1,31 @@ +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 { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication"; +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:` 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"; /** @@ -16,43 +36,139 @@ const DEFAULT_CATEGORY: MedicationCategory = "OTHER"; */ type CategoryClient = Pick< Prisma.TransactionClient, - "medicationCategoryAssignment" + "medicationCategoryAssignment" | "medicationCategoryLabel" >; -function normalizeCategory(input: unknown): MedicationCategory { - if (typeof input !== "string") return DEFAULT_CATEGORY; - return MEDICATION_CATEGORIES.includes(input as MedicationCategory) - ? (input as MedicationCategory) - : DEFAULT_CATEGORY; +function isBuiltIn(input: string): input is MedicationCategory { + return MEDICATION_CATEGORIES.includes(input as MedicationCategory); } -export async function getMedicationCategories( +/** + * 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> { +): Promise> { if (medicationIds.length === 0) return {}; const rows = await client.medicationCategoryAssignment.findMany({ where: { medicationId: { in: medicationIds } }, - select: { medicationId: true, category: true }, + select: { + medicationId: true, + category: true, + medication: { select: { userId: true } }, + }, }); - const map: Record = {}; + 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 = {}; for (const id of medicationIds) { - map[id] = DEFAULT_CATEGORY; + map[id] = { category: DEFAULT_CATEGORY, categoryLabel: null }; } + let dangling = 0; for (const row of rows) { - map[row.medicationId] = normalizeCategory(row.category); + 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> { + 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 { + 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 = prisma, -) { - const normalized = normalizeCategory(category); + client: CategoryClient & + Pick = prisma, +): Promise { + 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 }, @@ -71,3 +187,123 @@ export async function deleteMedicationCategory( 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 { + 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 { + 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 }; + }); +} diff --git a/src/lib/medications/__tests__/category-filter.test.ts b/src/lib/medications/__tests__/category-filter.test.ts new file mode 100644 index 000000000..2dc819a9a --- /dev/null +++ b/src/lib/medications/__tests__/category-filter.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + ALL_MEDICATION_CATEGORIES, + effectiveCategoryFilter, + filterMedicationsByCategory, + medicationCategoryOptions, +} from "@/lib/medications/category-filter"; +import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; + +const KEY = "custom:11111111-1111-4111-8111-111111111111"; +const t = (key: string) => `t:${key}`; + +const MEDS = [ + { id: "a", active: true, category: "THYROID", categoryLabel: null }, + { id: "b", active: true, category: KEY, categoryLabel: "Travel kit" }, + { id: "c", active: false, category: KEY, categoryLabel: "Travel kit" }, + { id: "d", active: false, category: "THYROID", categoryLabel: null }, +]; + +describe("medication category filter (v1.40, #1041)", () => { + it("offers each category in use once, labelled like the card badge", () => { + expect(medicationCategoryOptions(MEDS, t)).toEqual([ + { value: "THYROID", label: "t:medications.categoryThyroid" }, + { value: KEY, label: "Travel kit" }, + ]); + }); + + it("narrows the active and the inactive block alike", () => { + const active = MEDS.filter((m) => m.active); + const inactive = MEDS.filter((m) => !m.active); + expect(filterMedicationsByCategory(active, KEY).map((m) => m.id)).toEqual([ + "b", + ]); + expect(filterMedicationsByCategory(inactive, KEY).map((m) => m.id)).toEqual( + ["c"], + ); + expect( + filterMedicationsByCategory(active, ALL_MEDICATION_CATEGORIES).map( + (m) => m.id, + ), + ).toEqual(["a", "b"]); + }); + + it("falls back to All when the stored pick is no longer in use", () => { + const options = medicationCategoryOptions(MEDS, t); + expect(effectiveCategoryFilter(KEY, options)).toBe(KEY); + expect( + effectiveCategoryFilter( + "custom:99999999-9999-4999-8999-999999999999", + options, + ), + ).toBe(ALL_MEDICATION_CATEGORIES); + }); + + it("labels a custom category by its own label and an unresolved key as Other", () => { + expect(getMedicationCategoryLabel(KEY, t, "Travel kit")).toBe("Travel kit"); + expect(getMedicationCategoryLabel(KEY, t, null)).toBe( + "t:medications.categoryOther", + ); + expect(getMedicationCategoryLabel("VITAMIN", t)).toBe( + "t:medications.categoryVitamin", + ); + }); +}); diff --git a/src/lib/medications/category-filter.ts b/src/lib/medications/category-filter.ts new file mode 100644 index 000000000..67d1d08b8 --- /dev/null +++ b/src/lib/medications/category-filter.ts @@ -0,0 +1,66 @@ +/** + * v1.40 (#1041) — the category filter on the medications list. Pure, so the + * page and its tests share one definition of what the filter offers and what + * it keeps. + */ +import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; + +type Translator = ( + key: string, + params?: Record, +) => string; + +/** The "no filter" sentinel, the value `FilterBarSelect` resets to. */ +export const ALL_MEDICATION_CATEGORIES = "ALL"; + +interface CategorisedMedication { + category: string; + categoryLabel?: string | null; +} + +/** + * The categories the given medications use, each once, labelled the way the + * card badge labels it, sorted by label. The filter offers only these, so a + * pick can never empty the list. + */ +export function medicationCategoryOptions( + medications: readonly CategorisedMedication[], + t: Translator, +): { value: string; label: string }[] { + const byValue = new Map(); + for (const m of medications) { + if (!byValue.has(m.category)) { + byValue.set( + m.category, + getMedicationCategoryLabel(m.category, t, m.categoryLabel), + ); + } + } + return [...byValue.entries()] + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +/** + * The filter value that actually applies: a stored pick whose category no + * medication uses any more (deleted, or every medication moved) falls back to + * All rather than showing an empty list. + */ +export function effectiveCategoryFilter( + stored: string, + options: readonly { value: string }[], +): string { + return options.some((option) => option.value === stored) + ? stored + : ALL_MEDICATION_CATEGORIES; +} + +/** Keep the medications the filter admits, in their order. */ +export function filterMedicationsByCategory( + medications: readonly T[], + filter: string, +): T[] { + return filter === ALL_MEDICATION_CATEGORIES + ? [...medications] + : medications.filter((m) => m.category === filter); +} diff --git a/src/lib/medications/category-label.ts b/src/lib/medications/category-label.ts index 087a2f188..0dbabc118 100644 --- a/src/lib/medications/category-label.ts +++ b/src/lib/medications/category-label.ts @@ -38,10 +38,23 @@ const MEDICATION_CATEGORY_KEYS: Record = { OTHER: "medications.categoryOther", }; +/** + * v1.40 (#1041) — a category the person named themselves arrives as + * `custom:` with its decrypted label beside it (`categoryLabel` on the + * medication wire); that label wins. A custom key without a label (the server + * resolved it to nothing) reads as Other, like any unknown value. + */ export function getMedicationCategoryLabel( category: string, t: Translator, + customLabel?: string | null, ): string { + if (customLabel) return customLabel; const key = MEDICATION_CATEGORY_KEYS[category] ?? "medications.categoryOther"; return t(key); } + +/** The built-in category values, in the order the filter row lists them. */ +export const BUILT_IN_MEDICATION_CATEGORIES = Object.keys( + MEDICATION_CATEGORY_KEYS, +); diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 6af8d17d0..e40eeafb5 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -14,7 +14,10 @@ */ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { + resolveMedicationCategories, + type ResolvedMedicationCategory, +} from "@/lib/medication-category"; import { effectiveUnitsPerDose, estimateUnitsRunwayDays, @@ -200,9 +203,11 @@ export async function buildMedicationsList( } const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId)); - let categoryMap: Record = {}; + let categoryMap: Record = {}; try { - categoryMap = await getMedicationCategories(medications.map((m) => m.id)); + categoryMap = await resolveMedicationCategories( + medications.map((m) => m.id), + ); } catch { getEvent()?.addWarning("Medication categories could not be loaded"); } @@ -300,7 +305,10 @@ export async function buildMedicationsList( // v1.39.1 (#1033) — `schedules: []` plus `recordedSchedules` when // intake tracking is off (see `scheduleWireFields`). ...scheduleWireFields(m.trackIntake, schedulesDto), - category: categoryMap[m.id] ?? "OTHER", + category: categoryMap[m.id]?.category ?? "OTHER", + // v1.40 (#1041) — the decrypted label of a custom category; null for + // a built-in one, which every client localises from `category`. + categoryLabel: categoryMap[m.id]?.categoryLabel ?? null, // v1.32.25 — provenance echo. Surfacing the mirror source lets the // web UI and an operator tell an externally-mirrored row (today only // Apple Health) from a native HealthLog medication. NULL for a native diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 9f4d83bf7..4b1cfd108 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -249,6 +249,9 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], 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", diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index bdfc6ae33..c4b5d2ba1 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -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:`, 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 = { 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 = { }, }, }, + "/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:` 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 = { 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() }), }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 957de5197..905c53cf1 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -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:` 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:\`, 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:`); null for a built-in category, which a client localises from `category`. Read-only.", + ); + +export const medicationCategoryLabelResource = z + .object({ + key: z + .string() + .describe( + "`custom:`, 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( diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index b7f5b2f1a..9b8ff41aa 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -52,6 +52,13 @@ export const medicationKeys = { * layout PUT's response. */ medicationListLayout: () => ["medication-list-layout"] as const, + /** + * v1.40 (#1041) — the record's own medication categories + * (`GET /api/medications/categories`). Under the `["medications"]` prefix: + * a delete moves medications to Other, so the list must refetch with it, + * and a medication write changes the per-category counts. + */ + medicationCategories: () => ["medications", "categories"] as const, medicationCadence: (medicationId: string) => ["medications", medicationId, "cadence"] as const, medicationGlp1Details: (medicationId: string) => diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index b9e7a128d..4876b194b 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -225,6 +225,10 @@ export function recordActivityVerbLine( case "medication.update": return t("recordSharing.activityVerb.medicationUpdate", { name }); + case "medication.category.custom.create": + return t("recordSharing.activityVerb.medicationCategoryCreate", { + name, + }); case "medication.delete": return t("recordSharing.activityVerb.medicationDelete", { name }); case "medication.glp1.update": diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 0613a290d..5bb94f86e 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -333,6 +333,22 @@ const medicationEfficacyTargetSchema = z }) .passthrough(); +/** + * v1.40 (#1041) — a medication category the account named itself. Carried + * with its decrypted label (the label is re-encrypted on restore under the + * restoring instance's key) and restored BEFORE the medications, whose + * `category` names it by `key`. + */ +const customMedicationCategorySchema = z + .object({ + key: z.string().regex(/^custom:[0-9a-f-]{36}$/), + label: z.string().trim().min(1).max(40), + sortOrder: z.number().int().min(0).default(0), + isActive: z.boolean().default(true), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -361,7 +377,8 @@ const medicationSchema = z // v1.39.4 — the clinical category. Absent in older files. A string, not // the enum: a file from a newer server may name a category this one does // not know, and the restore reads that as OTHER rather than refusing the - // whole file. + // whole file. Since v1.40 it may be `custom:`, the key of an entry + // in `customMedicationCategories`. category: z.string().max(64).optional(), deliveryForm: z.enum(MedicationDeliveryForm).optional(), trackInjectionSites: z.boolean().optional(), @@ -1834,6 +1851,10 @@ export const backupPayloadSchema = z accountSettings: accountSettingsBackupSchema.nullable().default(null), measurements: z.array(measurementSchema).default([]), medications: z.array(medicationSchema).default([]), + // v1.40 — absent in older files, which carry no custom category either. + customMedicationCategories: z + .array(customMedicationCategorySchema) + .default([]), intakeEvents: z.array(intakeEventSchema).default([]), moodEntries: z.array(moodEntrySchema).default([]), // v1.15.0 — cycle-tracking tables. Default to empty arrays / null so a diff --git a/src/lib/validations/medication/base.ts b/src/lib/validations/medication/base.ts index fdd1ff527..6590fad67 100644 --- a/src/lib/validations/medication/base.ts +++ b/src/lib/validations/medication/base.ts @@ -63,8 +63,8 @@ export const rxNormCodeField = z "Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. NULL clears it; absent leaves it untouched.", ); /** - * Clinical-category values stored in the `medication_categories` side- - * table (TEXT column). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the + * Clinical-category values stored in the `medication_categories` table + * (TEXT column, the `MedicationCategoryAssignment` model). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the * wizard's Step 2 taxonomy can write a first-class bucket for those * two rows instead of collapsing them into `OTHER`. The column is a * plain TEXT field — no Prisma enum exists to migrate; the Zod values @@ -91,6 +91,63 @@ export const MEDICATION_CATEGORY_VALUES = [ export type MedicationCategoryValue = (typeof MEDICATION_CATEGORY_VALUES)[number]; +/** + * v1.40 (#1041) — a category the person named themselves is addressed by the + * key of its `MedicationCategoryLabel` row, `custom:`. The `category` + * field on the medication wire is a built-in value or such a key. + */ +export const CUSTOM_MEDICATION_CATEGORY_PREFIX = "custom:"; + +/** Per-account ceiling on custom medication categories (hidden ones count). */ +export const MAX_CUSTOM_MEDICATION_CATEGORIES = 20; + +const CUSTOM_MEDICATION_CATEGORY_KEY = /^custom:[0-9a-f-]{36}$/; + +/** Is `value` a custom-category key rather than a built-in value? */ +export function isCustomMedicationCategoryKey(value: string): boolean { + return value.startsWith(CUSTOM_MEDICATION_CATEGORY_PREFIX); +} + +/** + * The `category` a create or update body may carry: a built-in value or a + * well-formed custom key. Ownership of a custom key is checked by the route + * (422 `medications.category.unknown`), not here. + */ +export const medicationCategoryInputSchema = z + .union([ + z.enum(MEDICATION_CATEGORY_VALUES), + z.string().regex(CUSTOM_MEDICATION_CATEGORY_KEY, "Unknown category"), + ]) + .describe( + "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:`, the key of one of the caller's own categories.", + ); + +const customCategoryLabelSchema = z + .string() + .trim() + .min(1, "Label must not be empty") + .max(40, "Label must be at most 40 characters"); + +/** `POST /api/medications/categories` body. */ +export const createMedicationCategoryLabelSchema = z.object({ + label: customCategoryLabelSchema, +}); + +/** `PATCH /api/medications/categories/{key}` body: at least one field. */ +export const updateMedicationCategoryLabelSchema = z + .object({ + label: customCategoryLabelSchema.optional(), + sortOrder: z.number().int().min(0).max(1000).optional(), + isActive: z.boolean().optional(), + }) + .refine( + (v) => + v.label !== undefined || + v.sortOrder !== undefined || + v.isActive !== undefined, + "At least one field is required", + ); + /** * v1.4.25 W4d — Prisma-level treatment class. Orthogonal to * `MEDICATION_CATEGORY_VALUES` (the clinical taxonomy that lives in the diff --git a/src/lib/validations/medication/create-update.ts b/src/lib/validations/medication/create-update.ts index b774984ef..7337b34a4 100644 --- a/src/lib/validations/medication/create-update.ts +++ b/src/lib/validations/medication/create-update.ts @@ -1,7 +1,7 @@ import { z } from "zod/v4"; import { - MEDICATION_CATEGORY_VALUES, + medicationCategoryInputSchema, MEDICATION_DELIVERY_FORM_VALUES, MEDICATION_TREATMENT_CLASS_VALUES, atcCodeField, @@ -99,7 +99,7 @@ export const createMedicationSchema = z .object({ name: z.string().min(1).max(100), dose: z.string().min(1).max(50), - category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), + category: medicationCategoryInputSchema.optional(), /** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */ treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), /** v1.4.25 W4d — doses per pen/vial for inventory tracking. @@ -267,7 +267,7 @@ export const updateMedicationSchema = z .object({ name: z.string().min(1).max(100).optional(), dose: z.string().min(1).max(50).optional(), - category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), + category: medicationCategoryInputSchema.optional(), treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(), /** v1.16.10 — inventory units one dose consumes. Default 1. diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index e90d4748e..0f1488ed3 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -681,6 +681,14 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "create", ), + handler( + "app/api/medications/categories/route.ts", + "/api/medications/categories", + "POST", + "medications", + "manage", + "create", + ), handler( "app/api/nutrients/water/route.ts", "/api/nutrients/water", diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6d082054d..ec6b9db60 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -66,7 +66,7 @@ import { } from "@/lib/withings/ecg-waveform-codec"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { - getMedicationCategories, + resolveMedicationCategories, setMedicationCategory, } from "@/lib/medication-category"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; @@ -188,6 +188,7 @@ const COACH_RESULTS_JSON = JSON.stringify([ chart: { kind: "line", x: "period", series: ["sys"] }, }, ]); +const ROUND_TRIP_CATEGORY_KEY = "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10"; const DOSE_CHANGE_NOTE = "titration note, encrypted at rest"; const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)"; const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose"; @@ -223,6 +224,8 @@ const COUNT_BACK: Record< // No own `userId` column — reached through the drug, like the schedules. MedicationCategoryAssignment: (p, userId) => p.medicationCategoryAssignment.count({ where: { medication: { userId } } }), + MedicationCategoryLabel: (p, userId) => + p.medicationCategoryLabel.count({ where: { userId } }), MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }), MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }), MoodEntryTagLink: (p, userId) => @@ -427,9 +430,18 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, }); // Not OTHER: a missing row already reads as OTHER, so only a chosen value - // proves the category came back. + // proves the category came back. A category the person named, so the + // assignment can only resolve if the label came back before it. + await prisma.medicationCategoryLabel.create({ + data: { + userId: OWNER_ID, + key: ROUND_TRIP_CATEGORY_KEY, + labelEncrypted: encryptToBytes("Heart and circulation"), + sortOrder: 2, + }, + }); await prisma.medicationCategoryAssignment.create({ - data: { medicationId: medication.id, category: "THYROID" }, + data: { medicationId: medication.id, category: ROUND_TRIP_CATEGORY_KEY }, }); // The one row in this fixture that is also checked field by field after the // restore — see the assertion at the end of the test for why. @@ -2809,8 +2821,22 @@ describe("every model the plan claims two-ended survives a real restore", () => await prisma.medication.create({ data: { userId: OWNER_ID, name: "Unfiled", dose: "1 tab" }, }); + const travel = await prisma.medication.create({ + data: { userId: OWNER_ID, name: "Tamiflu", dose: "75 mg" }, + }); await setMedicationCategory(thyroid.id, "THYROID"); await setMedicationCategory(mood.id, "MENTAL_HEALTH"); + // v1.40 — a category the person named. Hidden, because a hidden + // category still files the medication and must travel with it. + await prisma.medicationCategoryLabel.create({ + data: { + userId: OWNER_ID, + key: ROUND_TRIP_CATEGORY_KEY, + labelEncrypted: encryptToBytes("Travel kit"), + isActive: false, + }, + }); + await setMedicationCategory(travel.id, ROUND_TRIP_CATEGORY_KEY); const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { purpose, @@ -2824,7 +2850,17 @@ describe("every model the plan claims two-ended survives a real restore", () => Levothyroxine: "THYROID", Sertraline: "MENTAL_HEALTH", Unfiled: "OTHER", + Tamiflu: ROUND_TRIP_CATEGORY_KEY, }); + expect(payload.customMedicationCategories).toEqual([ + { + key: ROUND_TRIP_CATEGORY_KEY, + label: "Travel kit", + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + }, + ]); await prisma.user.delete({ where: { id: OWNER_ID } }); await createOwner(prisma); @@ -2849,16 +2885,24 @@ describe("every model the plan claims two-ended survives a real restore", () => where: { userId: OWNER_ID }, select: { id: true, name: true }, }); - const categories = await getMedicationCategories( + const categories = await resolveMedicationCategories( restored.map((m) => m.id), ); expect( Object.fromEntries(restored.map((m) => [m.name, categories[m.id]])), ).toEqual({ - Levothyroxine: "THYROID", - Sertraline: "MENTAL_HEALTH", - Unfiled: "OTHER", + Levothyroxine: { category: "THYROID", categoryLabel: null }, + Sertraline: { category: "MENTAL_HEALTH", categoryLabel: null }, + Unfiled: { category: "OTHER", categoryLabel: null }, + Tamiflu: { + category: ROUND_TRIP_CATEGORY_KEY, + categoryLabel: "Travel kit", + }, }); + const label = await prisma.medicationCategoryLabel.findUniqueOrThrow({ + where: { key: ROUND_TRIP_CATEGORY_KEY }, + }); + expect(label.isActive).toBe(false); }, ); diff --git a/tests/integration/medication-custom-categories.test.ts b/tests/integration/medication-custom-categories.test.ts new file mode 100644 index 000000000..694359423 --- /dev/null +++ b/tests/integration/medication-custom-categories.test.ts @@ -0,0 +1,299 @@ +/** + * Custom medication categories (#1041) against a real Postgres, through the + * shipped route exports. + * + * What the file pins: + * - a category is created, listed with its medication count, renamed and + * hidden through its own routes, and capped at twenty per account; + * - a medication can be filed under one of the caller's own categories and + * the list and detail reads publish the decrypted label beside the key; + * - another account's key is refused (422 `medications.category.unknown`) + * on create and on update, and never resolves on a read; + * - deleting a category moves its medications to OTHER in the same + * transaction, so no medication is left pointing at a key that is gone; + * - a key left behind anyway (written past the routes) reads as OTHER. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "user-med-categories"; +const OTHER_USER = "user-med-categories-other"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + for (const id of [OWNER, OTHER_USER]) { + await prisma.user.create({ + data: { + id, + username: id, + email: `${id}@example.test`, + timezone: "UTC", + }, + }); + } + await signIn(OWNER); +}); + +function req(method: string, path: string, body?: unknown) { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function createCategory(label: string): Promise { + const { POST } = await import("@/app/api/medications/categories/route"); + const res = await POST(req("POST", "/api/medications/categories", { label })); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(201); + return body.data.key as string; +} + +async function createMedication(category?: string) { + const { POST } = await import("@/app/api/medications/route"); + return POST( + req("POST", "/api/medications", { + name: "Tamiflu", + dose: "75 mg", + ...(category ? { category } : {}), + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); +} + +describe("custom medication categories", () => { + it("files a medication under an own category and publishes its label", async () => { + const key = await createCategory(" Travel kit "); + expect(key).toMatch(/^custom:[0-9a-f-]{36}$/); + + const created = await createMedication(key); + const createdBody = await created.json(); + expect(created.status, JSON.stringify(createdBody)).toBe(201); + expect(createdBody.data.category).toBe(key); + expect(createdBody.data.categoryLabel).toBe("Travel kit"); + + const { GET: list } = await import("@/app/api/medications/route"); + const listBody = await (await list()).json(); + expect(listBody.data[0]).toMatchObject({ + category: key, + categoryLabel: "Travel kit", + }); + + const { GET: detail } = await import("@/app/api/medications/[id]/route"); + const detailBody = await ( + await detail(req("GET", `/api/medications/${createdBody.data.id}`), { + params: Promise.resolve({ id: createdBody.data.id }), + }) + ).json(); + expect(detailBody.data).toMatchObject({ + category: key, + categoryLabel: "Travel kit", + }); + + const { GET: categories } = + await import("@/app/api/medications/categories/route"); + const categoriesBody = await (await categories()).json(); + expect(categoriesBody.data.categories).toEqual([ + { + key, + label: "Travel kit", + sortOrder: 0, + isActive: true, + medicationCount: 1, + }, + ]); + + // The label is ciphertext at rest. + const row = + await getPrismaClient().medicationCategoryLabel.findUniqueOrThrow({ + where: { key }, + }); + expect(Buffer.from(row.labelEncrypted).toString("utf8")).not.toContain( + "Travel kit", + ); + }); + + it("refuses another account's key on create and on update", async () => { + await signIn(OTHER_USER); + const foreignKey = await createCategory("Theirs"); + await signIn(OWNER); + + const refused = await createMedication(foreignKey); + const refusedBody = await refused.json(); + expect(refused.status).toBe(422); + expect(refusedBody.meta.errorCode).toBe("medications.category.unknown"); + expect(await getPrismaClient().medication.count()).toBe(0); + + const ok = await createMedication("VITAMIN"); + const okBody = await ok.json(); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const updated = await PUT( + req("PUT", `/api/medications/${okBody.data.id}`, { + category: foreignKey, + }), + { params: Promise.resolve({ id: okBody.data.id }) }, + ); + expect(updated.status).toBe(422); + expect((await updated.json()).meta.errorCode).toBe( + "medications.category.unknown", + ); + const assignment = + await getPrismaClient().medicationCategoryAssignment.findUniqueOrThrow({ + where: { medicationId: okBody.data.id }, + }); + expect(assignment.category).toBe("VITAMIN"); + }); + + it("keeps the category on an update that does not name it", async () => { + const key = await createCategory("Heart"); + const created = await (await createMedication(key)).json(); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${created.data.id}`, { name: "Renamed" }), + { params: Promise.resolve({ id: created.data.id }) }, + ); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(200); + expect(body.data).toMatchObject({ category: key, categoryLabel: "Heart" }); + }); + + it("renames, hides and caps", async () => { + const key = await createCategory("Heart"); + const { PATCH } = + await import("@/app/api/medications/categories/[key]/route"); + const res = await PATCH( + req("PATCH", `/api/medications/categories/${key}`, { + label: "Heart and vessels", + isActive: false, + sortOrder: 4, + }), + { params: Promise.resolve({ key }) }, + ); + expect(await res.json()).toMatchObject({ + data: { + key, + label: "Heart and vessels", + isActive: false, + sortOrder: 4, + medicationCount: 0, + }, + }); + + // Hidden ones count toward the cap: nineteen more fill it. + for (let i = 0; i < 19; i += 1) await createCategory(`Group ${i}`); + const { POST } = await import("@/app/api/medications/categories/route"); + const over = await POST( + req("POST", "/api/medications/categories", { label: "One too many" }), + ); + expect(over.status).toBe(422); + expect((await over.json()).meta.errorCode).toBe( + "medications.category.limitReached", + ); + }); + + it("does not let one account rename or delete another's category", async () => { + await signIn(OTHER_USER); + const foreignKey = await createCategory("Theirs"); + await signIn(OWNER); + const { PATCH, DELETE } = + await import("@/app/api/medications/categories/[key]/route"); + const patched = await PATCH( + req("PATCH", `/api/medications/categories/${foreignKey}`, { + label: "Mine now", + }), + { params: Promise.resolve({ key: foreignKey }) }, + ); + expect(patched.status).toBe(404); + const deleted = await DELETE( + req("DELETE", `/api/medications/categories/${foreignKey}`), + { params: Promise.resolve({ key: foreignKey }) }, + ); + expect(deleted.status).toBe(404); + expect( + await getPrismaClient().medicationCategoryLabel.count({ + where: { key: foreignKey }, + }), + ).toBe(1); + }); + + it("moves the medications of a deleted category to OTHER", async () => { + const key = await createCategory("Short courses"); + const first = await (await createMedication(key)).json(); + const second = await (await createMedication(key)).json(); + const untouched = await (await createMedication("THYROID")).json(); + + const { DELETE } = + await import("@/app/api/medications/categories/[key]/route"); + const res = await DELETE( + req("DELETE", `/api/medications/categories/${key}`), + { params: Promise.resolve({ key }) }, + ); + expect(await res.json()).toEqual({ + data: { key, movedCount: 2 }, + error: null, + }); + + const rows = await getPrismaClient().medicationCategoryAssignment.findMany({ + orderBy: { medicationId: "asc" }, + }); + const byId = Object.fromEntries( + rows.map((r) => [r.medicationId, r.category]), + ); + // The stored value itself, not what a read resolves it to: a dangling key + // would also READ as OTHER, which is exactly what this must not rely on. + expect(byId[first.data.id]).toBe("OTHER"); + expect(byId[second.data.id]).toBe("OTHER"); + expect(byId[untouched.data.id]).toBe("THYROID"); + expect(rows.filter((r) => r.category.startsWith("custom:"))).toEqual([]); + }); + + it("reads a key with no label as OTHER", async () => { + const created = await (await createMedication("VITAMIN")).json(); + await getPrismaClient().medicationCategoryAssignment.update({ + where: { medicationId: created.data.id }, + data: { category: "custom:00000000-0000-4000-8000-000000000000" }, + }); + const { GET } = await import("@/app/api/medications/[id]/route"); + const body = await ( + await GET(req("GET", `/api/medications/${created.data.id}`), { + params: Promise.resolve({ id: created.data.id }), + }) + ).json(); + expect(body.data).toMatchObject({ category: "OTHER", categoryLabel: null }); + }); +}); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index cb4fea195..32422711b 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -281,7 +281,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(78); }); }); @@ -2250,6 +2250,18 @@ proveManageRoute("POST /api/cycle/symptoms/custom", { })) === 1, }); +proveManageRoute("POST /api/medications/categories", { + route: "/api/medications/categories", + prepare: async (ownerId) => ({ ownerId }), + body: () => ({ label: "Travel kit" }), + ok: 201, + auditAction: "medication.category.custom.create", + applied: async ({ ownerId }) => + (await getPrismaClient().medicationCategoryLabel.count({ + where: { userId: ownerId }, + })) === 1, +}); + proveManageRoute("POST /api/nutrients/water", { route: "/api/nutrients/water", prepare: async (ownerId) => { @@ -2580,13 +2592,13 @@ describe("the conditions the admissions were granted on", () => { describe("the complete MANAGE handler matrix", () => { it("registers one strict actor-and-effect driver for every admission", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(STRICT_DRIVER_KEYS.size).toBe(77); + expect(STRICT_DRIVER_KEYS.size).toBe(78); expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected); }); it("executes every registered driver through its owned effect and actor audit", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(REAL_EFFECT_KEYS.size).toBe(77); + expect(REAL_EFFECT_KEYS.size).toBe(78); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); }); From 802759df02ac1b64e9499d05b20ea83a09a33218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 21:38:05 +0200 Subject: [PATCH 03/18] Translate the custom medication categories into every locale --- messages/de.json | 29 ++++++++++++++++++++++++++++- messages/en.json | 29 ++++++++++++++++++++++++++++- messages/es.json | 29 ++++++++++++++++++++++++++++- messages/fr.json | 29 ++++++++++++++++++++++++++++- messages/it.json | 29 ++++++++++++++++++++++++++++- messages/ko.json | 29 ++++++++++++++++++++++++++++- messages/pl.json | 29 ++++++++++++++++++++++++++++- 7 files changed, 196 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index 623f3de78..9040d6e0c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2277,6 +2277,32 @@ "placeholder": "z. B. 1,5 oder 1 1/2", "invalid": "Gib eine Zahl über 0 und bis 100 ein, zum Beispiel 1,5 oder 1 1/2.", "preview": "Zählt als {value} pro Dosis" + }, + "category": { + "custom": { + "add": "Neue Kategorie", + "label": "Name der Kategorie", + "labelPlaceholder": "z. B. Reiseapotheke", + "create": "Hinzufügen", + "limitReached": "Du hast die Grenze von 20 eigenen Kategorien erreicht.", + "saveFailed": "Das wurde nicht gespeichert. Bitte versuch es noch einmal.", + "bpGateNote": "Die Blutdruck-Auswertung zählt nur Medikamente in der eingebauten Kategorie Blutdruck.", + "manage": "Kategorien verwalten", + "manageDescription": "Deine eigenen Kategorien, neben den eingebauten.", + "hidden": "Ausgeblendet", + "count": "{count} Medikamente", + "rename": "Umbenennen", + "hide": "In der Auswahl ausblenden", + "show": "In der Auswahl zeigen", + "delete": "Löschen", + "deleteTitle": "Diese Kategorie löschen?", + "deleteBody": "{count} Medikamente in dieser Kategorie wandern zu Sonstige. Die Medikamente selbst bleiben.", + "movedToOther": "Kategorie gelöscht. {count} Medikamente sind jetzt unter Sonstige." + }, + "filter": { + "label": "Kategorie", + "all": "Alle Kategorien" + } } }, "charts": { @@ -11269,7 +11295,8 @@ "documentDiscard": "{name} hat ein Dokument verworfen", "medicationIntakeImportFinished": "Der von {name} gestartete Einnahme-Import ist fertig", "medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen", - "vaccinationBoosterPlanned": "{name} hat eine Auffrischung geplant" + "vaccinationBoosterPlanned": "{name} hat eine Auffrischung geplant", + "medicationCategoryCreate": "{name} hat eine Medikamentenkategorie angelegt" }, "section": { "measurements": { diff --git a/messages/en.json b/messages/en.json index d1ed93c4a..387453d17 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2277,6 +2277,32 @@ "placeholder": "e.g. 1.5 or 1 1/2", "invalid": "Enter a number above 0 and up to 100, for example 1.5 or 1 1/2.", "preview": "Counts as {value} per dose" + }, + "category": { + "custom": { + "add": "New category", + "label": "Category name", + "labelPlaceholder": "e.g. Travel kit", + "create": "Add", + "limitReached": "You have reached the limit of 20 own categories.", + "saveFailed": "That did not save. Please try again.", + "bpGateNote": "Blood pressure insights only count medications in the built-in Blood pressure category.", + "manage": "Manage categories", + "manageDescription": "Your own categories, shown next to the built-in ones.", + "hidden": "Hidden", + "count": "{count} medications", + "rename": "Rename", + "hide": "Hide from the picker", + "show": "Show in the picker", + "delete": "Delete", + "deleteTitle": "Delete this category?", + "deleteBody": "{count} medications filed under it move to Other. The medications themselves stay.", + "movedToOther": "Category deleted. {count} medications moved to Other." + }, + "filter": { + "label": "Category", + "all": "All categories" + } } }, "charts": { @@ -11269,7 +11295,8 @@ "documentDiscard": "{name} discarded a document", "medicationIntakeImportFinished": "The dose import {name} started has finished", "medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded", - "vaccinationBoosterPlanned": "{name} planned a booster" + "vaccinationBoosterPlanned": "{name} planned a booster", + "medicationCategoryCreate": "{name} added a medication category" }, "section": { "measurements": { diff --git a/messages/es.json b/messages/es.json index c0f17a81e..cbbbcfcf1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2277,6 +2277,32 @@ "placeholder": "p. ej., 1,5 o 1 1/2", "invalid": "Introduce un número mayor que 0 y hasta 100, por ejemplo 1,5 o 1 1/2.", "preview": "Cuenta como {value} por dosis" + }, + "category": { + "custom": { + "add": "Nueva categoría", + "label": "Nombre de la categoría", + "labelPlaceholder": "p. ej. Botiquín de viaje", + "create": "Añadir", + "limitReached": "Has alcanzado el límite de 20 categorías propias.", + "saveFailed": "No se ha guardado. Inténtalo de nuevo.", + "bpGateNote": "El análisis de la presión arterial solo cuenta los medicamentos de la categoría integrada Presión arterial.", + "manage": "Gestionar categorías", + "manageDescription": "Tus propias categorías, junto a las integradas.", + "hidden": "Oculta", + "count": "{count} medicamentos", + "rename": "Renombrar", + "hide": "Ocultar en el selector", + "show": "Mostrar en el selector", + "delete": "Eliminar", + "deleteTitle": "¿Eliminar esta categoría?", + "deleteBody": "{count} medicamentos de esta categoría pasan a Otros. Los medicamentos se conservan.", + "movedToOther": "Categoría eliminada. {count} medicamentos pasan a Otros." + }, + "filter": { + "label": "Categoría", + "all": "Todas las categorías" + } } }, "charts": { @@ -11269,7 +11295,8 @@ "documentDiscard": "{name} descartó un documento", "medicationIntakeImportFinished": "La importación de dosis que inició {name} ha terminado", "medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}", - "vaccinationBoosterPlanned": "{name} planificó un refuerzo" + "vaccinationBoosterPlanned": "{name} planificó un refuerzo", + "medicationCategoryCreate": "{name} añadió una categoría de medicamentos" }, "section": { "measurements": { diff --git a/messages/fr.json b/messages/fr.json index c8b7ddd3f..ecf204b16 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2277,6 +2277,32 @@ "placeholder": "p. ex. 1,5 ou 1 1/2", "invalid": "Saisissez un nombre supérieur à 0 et jusqu’à 100, par exemple 1,5 ou 1 1/2.", "preview": "Compte comme {value} par dose" + }, + "category": { + "custom": { + "add": "Nouvelle catégorie", + "label": "Nom de la catégorie", + "labelPlaceholder": "p. ex. Trousse de voyage", + "create": "Ajouter", + "limitReached": "Tu as atteint la limite de 20 catégories personnelles.", + "saveFailed": "L'enregistrement a échoué. Réessaie.", + "bpGateNote": "L'analyse de la tension ne compte que les médicaments de la catégorie intégrée Tension artérielle.", + "manage": "Gérer les catégories", + "manageDescription": "Tes propres catégories, à côté des catégories intégrées.", + "hidden": "Masquée", + "count": "{count} médicaments", + "rename": "Renommer", + "hide": "Masquer dans le sélecteur", + "show": "Afficher dans le sélecteur", + "delete": "Supprimer", + "deleteTitle": "Supprimer cette catégorie ?", + "deleteBody": "{count} médicaments de cette catégorie passent dans Autre. Les médicaments eux-mêmes restent.", + "movedToOther": "Catégorie supprimée. {count} médicaments passent dans Autre." + }, + "filter": { + "label": "Catégorie", + "all": "Toutes les catégories" + } } }, "charts": { @@ -11269,7 +11295,8 @@ "documentDiscard": "{name} a écarté un document", "medicationIntakeImportFinished": "L'import de prises lancé par {name} est terminé", "medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}", - "vaccinationBoosterPlanned": "{name} a planifié un rappel" + "vaccinationBoosterPlanned": "{name} a planifié un rappel", + "medicationCategoryCreate": "{name} a ajouté une catégorie de médicaments" }, "section": { "measurements": { diff --git a/messages/it.json b/messages/it.json index cd85c6b40..310c84d24 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2277,6 +2277,32 @@ "placeholder": "ad es. 1,5 o 1 1/2", "invalid": "Inserisci un numero maggiore di 0 e fino a 100, ad esempio 1,5 o 1 1/2.", "preview": "Conta come {value} per dose" + }, + "category": { + "custom": { + "add": "Nuova categoria", + "label": "Nome della categoria", + "labelPlaceholder": "es. Kit da viaggio", + "create": "Aggiungi", + "limitReached": "Hai raggiunto il limite di 20 categorie personali.", + "saveFailed": "Il salvataggio non è riuscito. Riprova.", + "bpGateNote": "L'analisi della pressione conta solo i farmaci nella categoria integrata Pressione sanguigna.", + "manage": "Gestisci categorie", + "manageDescription": "Le tue categorie, accanto a quelle integrate.", + "hidden": "Nascosta", + "count": "{count} farmaci", + "rename": "Rinomina", + "hide": "Nascondi nella selezione", + "show": "Mostra nella selezione", + "delete": "Elimina", + "deleteTitle": "Eliminare questa categoria?", + "deleteBody": "{count} farmaci di questa categoria passano in Altro. I farmaci restano.", + "movedToOther": "Categoria eliminata. {count} farmaci passano in Altro." + }, + "filter": { + "label": "Categoria", + "all": "Tutte le categorie" + } } }, "charts": { @@ -11269,7 +11295,8 @@ "documentDiscard": "{name} ha scartato un documento", "medicationIntakeImportFinished": "L'importazione delle dosi avviata da {name} è terminata", "medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}", - "vaccinationBoosterPlanned": "{name} ha pianificato un richiamo" + "vaccinationBoosterPlanned": "{name} ha pianificato un richiamo", + "medicationCategoryCreate": "{name} ha aggiunto una categoria di farmaci" }, "section": { "measurements": { diff --git a/messages/ko.json b/messages/ko.json index 2ec2108d9..c225b1dbc 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2277,6 +2277,32 @@ "placeholder": "예: 1.5 또는 1 1/2", "invalid": "0보다 크고 100 이하인 숫자를 입력하세요. 예: 1.5 또는 1 1/2", "preview": "1회분당 {value}(으)로 계산돼요" + }, + "category": { + "custom": { + "add": "새 카테고리", + "label": "카테고리 이름", + "labelPlaceholder": "예: 여행용 상비약", + "create": "추가", + "limitReached": "나만의 카테고리는 최대 20개까지 만들 수 있어요.", + "saveFailed": "저장하지 못했어요. 다시 시도해 주세요.", + "bpGateNote": "혈압 분석은 기본 제공되는 혈압 카테고리의 약만 계산해요.", + "manage": "카테고리 관리", + "manageDescription": "기본 카테고리 옆에 보이는 나만의 카테고리예요.", + "hidden": "숨김", + "count": "약 {count}개", + "rename": "이름 바꾸기", + "hide": "선택 목록에서 숨기기", + "show": "선택 목록에 표시", + "delete": "삭제", + "deleteTitle": "이 카테고리를 삭제할까요?", + "deleteBody": "이 카테고리의 약 {count}개가 기타로 옮겨져요. 약 자체는 그대로 남아요.", + "movedToOther": "카테고리를 삭제했어요. 약 {count}개가 기타로 옮겨졌어요." + }, + "filter": { + "label": "카테고리", + "all": "모든 카테고리" + } } }, "charts": { @@ -11269,7 +11295,8 @@ "documentDiscard": "{name} 님이 문서를 폐기했어요", "medicationIntakeImportFinished": "{name} 님이 시작한 복용 기록 불러오기가 끝났어요", "medicationOneShotReconciled": "{name} 님이 기록한 복용에 따라 일회성 약이 시작되거나 종료됐어요", - "vaccinationBoosterPlanned": "{name} 님이 추가 접종을 계획했어요" + "vaccinationBoosterPlanned": "{name} 님이 추가 접종을 계획했어요", + "medicationCategoryCreate": "{name} 님이 약 카테고리를 추가했어요" }, "section": { "measurements": { diff --git a/messages/pl.json b/messages/pl.json index d1fa76a78..363501fcf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2277,6 +2277,32 @@ "placeholder": "np. 1,5 lub 1 1/2", "invalid": "Wpisz liczbę większą od 0 i nie większą niż 100, na przykład 1,5 lub 1 1/2.", "preview": "Liczy się jako {value} na dawkę" + }, + "category": { + "custom": { + "add": "Nowa kategoria", + "label": "Nazwa kategorii", + "labelPlaceholder": "np. Apteczka podróżna", + "create": "Dodaj", + "limitReached": "Osiągnięto limit 20 własnych kategorii.", + "saveFailed": "Nie udało się zapisać. Spróbuj ponownie.", + "bpGateNote": "Analiza ciśnienia uwzględnia tylko leki z wbudowanej kategorii Ciśnienie krwi.", + "manage": "Zarządzaj kategoriami", + "manageDescription": "Twoje własne kategorie, obok wbudowanych.", + "hidden": "Ukryta", + "count": "Leki: {count}", + "rename": "Zmień nazwę", + "hide": "Ukryj w wyborze", + "show": "Pokaż w wyborze", + "delete": "Usuń", + "deleteTitle": "Usunąć tę kategorię?", + "deleteBody": "Leki w tej kategorii ({count}) przejdą do kategorii Inne. Same leki zostaną.", + "movedToOther": "Kategoria usunięta. Leki przeniesione do kategorii Inne: {count}." + }, + "filter": { + "label": "Kategoria", + "all": "Wszystkie kategorie" + } } }, "charts": { @@ -11269,7 +11295,8 @@ "documentDiscard": "{name} odrzucił dokument", "medicationIntakeImportFinished": "Import przyjęć rozpoczęty przez {name} zakończył się", "medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}", - "vaccinationBoosterPlanned": "{name} zaplanował dawkę przypominającą" + "vaccinationBoosterPlanned": "{name} zaplanował dawkę przypominającą", + "medicationCategoryCreate": "{name} dodał kategorię leków" }, "section": { "measurements": { From b7458f06073d70f9d23e7720a0cd757ddd156dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 21:59:42 +0200 Subject: [PATCH 04/18] Stub the custom category read in the export route tests The full backup now reads the account's own medication categories, and three route tests that drive the payload builder against a mocked client had no delegate for it. --- src/app/api/export/__tests__/per-type-routes.test.ts | 6 ++++++ src/app/api/export/__tests__/soft-delete-filter.test.ts | 3 +++ src/app/api/export/encrypted/__tests__/route.test.ts | 3 +++ 3 files changed, 12 insertions(+) diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index a6a01a3e6..3fa921aee 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -23,6 +23,9 @@ vi.mock("@/lib/db", () => ({ passkey: { count: vi.fn(async () => 0) }, measurement: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, + // v1.40 — the custom medication categories the backup carries. + medicationCategoryLabel: { findMany: vi.fn().mockResolvedValue([]) }, + medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, moodTag: { findMany: vi.fn() }, @@ -129,6 +132,9 @@ beforeEach(() => { // `resetAllMocks` clears the inline defaults, and the full-backup payload // builder reads these unconditionally. vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationCategoryLabel.findMany).mockResolvedValue( + [] as never, + ); vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( null as never, ); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 45cb98d38..d0d5da720 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -22,6 +22,9 @@ vi.mock("@/lib/db", () => ({ $queryRaw: vi.fn(), measurement: { findMany: vi.fn(), count: vi.fn() }, medication: { findMany: vi.fn() }, + // v1.40 — the custom medication categories the backup carries. + medicationCategoryLabel: { findMany: vi.fn().mockResolvedValue([]) }, + medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() }, moodEntry: { findMany: vi.fn(), count: vi.fn() }, moodTag: { findMany: vi.fn() }, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 3df29eb17..d83552725 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -12,6 +12,9 @@ vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: vi.fn().mockResolvedValue([]) }, medication: { findMany: vi.fn().mockResolvedValue([]) }, + // v1.40 — the custom medication categories the backup carries. + medicationCategoryLabel: { findMany: vi.fn().mockResolvedValue([]) }, + medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, moodTag: { findMany: vi.fn().mockResolvedValue([]) }, From 9b71231010e166ea9e95b5796ab672941393e678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 22:00:07 +0200 Subject: [PATCH 05/18] Exempt the medication category routes from the module gate They are raw CRUD over the account's own rows, the data-layer reasoning every other medication route is exempt on. --- src/app/api/__tests__/module-route-gate-inventory.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 627962fe8..d3e4e7ecc 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -266,6 +266,10 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // schedule + intake history intact. "src/app/api/medications/route.ts", "src/app/api/medications/layout/route.ts", + // v1.40 (#1041) — the person's own category vocabulary: the same row + // store, the same data-layer reasoning. + "src/app/api/medications/categories/route.ts", + "src/app/api/medications/categories/[key]/route.ts", "src/app/api/medications/compliance/route.ts", "src/app/api/medications/intake/route.ts", "src/app/api/medications/intake/bulk/route.ts", From a14174fe4d3e03f9872aacaa1a653165a6b7a71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 22:39:25 +0200 Subject: [PATCH 06/18] Let a medication have several courses A medication taken now and then (#1024) keeps one row and gains course rows: calendar spans, both ends inclusive, an optional encrypted note, no schedule or dose of their own. Courses never overlap and every course but the latest has ended, so the latest course is always the one the medication's startsOn/endsOn project. src/lib/medications/courses.ts is the only writer of those two columns for a medication with courses; it writes under a per-medication advisory lock and re-projects in the same transaction, so reminders, the next-due line and intakeActionable keep reading the columns and stay right. - Migration 0368 adds medication_courses and backfills one course per medication window (an end-only window starts on the creation day, or on its end day when that lies earlier); a medication without a window stays course-less. Idempotent, data-preserving. - GET/POST /api/medications/{id}/courses and PATCH/DELETE .../courses/{courseId} (owner-only); create seeds the first course from a window; the medication PUT edits the latest course and refuses a window that would break the rules (medications.course.*). - List, detail, create and update carry courses, courseCount, previousCourseEndedOn and canStartCourse, resolved on the server. - Compliance expands over the span of all courses and drops the slots between them; every compliance read selects the courses. A rolling cadence is not anchored by an intake from a previous course. - The card names the last course's end and the running course's number; the detail Lifecycle group lists the courses with start, edit and delete. The Coach compliance block, the MCP resource, the doctor report window and table, the clinician view and FHIR (one MedicationStatement per course with effectivePeriod and status) read the courses. - Backup carries courses per medication and the restore writes them and re-projects; an older file derives the one course its window describes. Wipe plan, encrypted columns, rotation and OpenAPI follow. --- docs/api/openapi.yaml | 467 +++++++++++++++- .../migration.sql | 54 ++ prisma/schema.prisma | 35 ++ scripts/rotate-encryption-key.ts | 8 + .../destructive-control-guard.test.ts | 7 + .../module-route-gate-inventory.test.ts | 3 + .../health-record/__tests__/route.test.ts | 1 + src/app/api/insights/cards/route.ts | 3 + src/app/api/insights/comprehensive/route.ts | 3 + .../medications/[id]/__tests__/route.test.ts | 13 +- .../api/medications/[id]/compliance/route.ts | 3 + .../[id]/courses/[courseId]/route.ts | 119 +++++ src/app/api/medications/[id]/courses/route.ts | 125 +++++ src/app/api/medications/[id]/route.ts | 63 +++ .../api/medications/__tests__/route.test.ts | 13 +- src/app/api/medications/compliance/route.ts | 3 + src/app/api/medications/route.ts | 54 ++ src/app/medications/page-client.tsx | 6 + src/components/clinician/clinician-view.tsx | 7 +- src/components/clinician/report-sections.tsx | 47 +- .../card-parts/medication-state-badges.tsx | 34 +- src/components/medications/course-fields.ts | 34 ++ .../detail/medication-detail-tabs.tsx | 15 + .../medications/glp1-medication-card.tsx | 11 + .../medications/medication-card.tsx | 11 + .../medications/medication-table.tsx | 24 +- .../medications/sections/courses-section.tsx | 382 +++++++++++++ .../coach/snapshot-blocks/compliance-block.ts | 22 + src/lib/ai/coach/snapshot.ts | 7 +- .../__tests__/compliance-courses.test.ts | 118 ++++ src/lib/analytics/compliance/adapters.ts | 23 +- src/lib/analytics/compliance/cycle.ts | 10 +- src/lib/analytics/compliance/ledger.ts | 33 +- src/lib/analytics/compliance/occurrences.ts | 19 +- src/lib/analytics/compliance/types.ts | 62 +++ .../analytics/schedule-anchored-compliance.ts | 3 + src/lib/crypto/encrypted-columns.ts | 1 + src/lib/data-wipe/wipe-plan.ts | 2 + .../medication-mood-wellness-section.ts | 18 +- src/lib/doctor-report-types.ts | 13 + .../__tests__/leaf-gating-sweep.test.ts | 1 + src/lib/doctor-report/collect.ts | 74 ++- .../__tests__/full-backup-payload.test.ts | 46 ++ src/lib/export/backup-plan.ts | 4 + src/lib/export/full-backup-payload.ts | 12 + src/lib/export/restore-backup.ts | 58 ++ src/lib/fhir/__tests__/build-bundle.test.ts | 32 ++ src/lib/fhir/resources/medications.ts | 58 +- src/lib/fhir/types.ts | 2 +- src/lib/insights/blood-pressure-status.ts | 3 + .../insights/correlation-channel-series.ts | 3 + src/lib/insights/features.ts | 3 + .../insights/medication-compliance-status.ts | 3 + src/lib/mcp/resources.ts | 15 +- .../__tests__/course-window.test.ts | 162 ++++++ src/lib/medications/course-window.ts | 152 ++++++ src/lib/medications/courses.ts | 503 ++++++++++++++++++ .../medications/efficacy/build-efficacy.ts | 3 + src/lib/medications/list-read.ts | 7 + .../scheduling/__tests__/recurrence.test.ts | 23 + src/lib/medications/scheduling/recurrence.ts | 19 +- src/lib/openapi/error-codes.ts | 7 + src/lib/openapi/routes/medications/paths.ts | 142 ++++- src/lib/openapi/routes/medications/schemas.ts | 53 ++ src/lib/targets/build-response.ts | 7 +- src/lib/targets/types.ts | 2 + src/lib/validations/backup.ts | 18 + src/lib/validations/medication/course.ts | 54 ++ src/lib/validations/medication/index.ts | 1 + tests/integration/backup-round-trip.test.ts | 21 + tests/integration/medication-courses.test.ts | 361 +++++++++++++ 71 files changed, 3635 insertions(+), 90 deletions(-) create mode 100644 src/app/api/medications/[id]/courses/[courseId]/route.ts create mode 100644 src/app/api/medications/[id]/courses/route.ts create mode 100644 src/components/medications/course-fields.ts create mode 100644 src/components/medications/sections/courses-section.tsx create mode 100644 src/lib/analytics/__tests__/compliance-courses.test.ts create mode 100644 src/lib/medications/__tests__/course-window.test.ts create mode 100644 src/lib/medications/course-window.ts create mode 100644 src/lib/medications/courses.ts create mode 100644 src/lib/validations/medication/course.ts create mode 100644 tests/integration/medication-courses.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ab0c02b1a..c475bbc1a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5874,9 +5874,8 @@ paths: 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. + 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." parameters: - in: path name: key @@ -5899,7 +5898,6 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationCategoryResponse" "401": *a1 - "403": *a11 "404": description: Not one of the record's own categories. content: @@ -5912,9 +5910,9 @@ paths: 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. + 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." parameters: - in: path name: key @@ -5931,7 +5929,6 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationCategoryResponse" "401": *a1 - "403": *a11 "404": description: Not one of the record's own categories. content: @@ -5940,6 +5937,145 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/medications/{id}/courses: + get: + tags: + - Medications + summary: Read a medication's courses + description: v1.40 (#1024) — the same resolved course fields the list and detail reads carry. Owner-only. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The courses. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationCoursesResponse" + "401": *a1 + "404": + description: Medication not found (or another user's). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + post: + tags: + - Medications + summary: Start a course + description: "v1.40 (#1024) — adds a course and, in the same transaction, rewrites the medication's `startsOn`/`endsOn` + to the latest course, so reminders and `intakeActionable` follow with no further write. Refusals: 422 + `medications.course.overlap`, `medications.course.currentOpen`, `medications.course.oneShot`, + `medications.course.invalidRange`, `medications.course.invalid` (body). Owner-only." + parameters: + - in: path + name: id + schema: + type: string + required: true + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationCourseRequest" + responses: + "201": + description: The new course. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationCourseResponse" + "401": *a1 + "404": + description: Medication not found (or another user's). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 + "422": *a4 + "429": *a5 + /api/medications/{id}/courses/{courseId}: + patch: + tags: + - Medications + summary: Edit a course + description: v1.40 (#1024) — field by field; the medication's window is re-projected in the same transaction. Same + refusals as the create. Owner-only. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: courseId + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationCourseRequest" + responses: + "200": + description: The edited course. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationCourseResponse" + "401": *a1 + "404": + description: Course not found (`medications.course.notFound`), or another user's. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + delete: + tags: + - Medications + summary: Delete a course + description: v1.40 (#1024) — deletes the course and re-projects the medication's window. Deleting the only course makes + the medication continuous again (both window columns cleared). Intakes are not touched. Owner-only. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: courseId + schema: + type: string + required: true + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteMedicationCourseResponse" + "401": *a1 + "404": + description: Course not found (`medications.course.notFound`), or another user's. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/medications/layout: get: tags: @@ -6053,7 +6189,9 @@ paths: 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`. + `medications.category.unknown`. v1.40 (#1024) — `startsOn`/`endsOn` now edit the latest course (created when + there is none); the same course refusals apply (422 `medications.course.*`), and clearing both on a medication + with several courses is 422 `medications.course.windowRequired`. parameters: - in: path name: id @@ -24720,6 +24858,52 @@ components: 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." + CreateMedicationCourseRequest: + type: object + properties: + startsOn: + description: First day of the course (ISO `YYYY-MM-DD`, inclusive, the person's calendar). + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + endsOn: + description: Last day (inclusive). NULL or absent = open; only the latest course may be open. + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + note: + description: Why this course, in the person's words. Encrypted at rest. + anyOf: + - type: string + maxLength: 280 + - type: "null" + required: + - startsOn + description: v1.40 — start a course. Refused when it shares a day with another course (422 + `medications.course.overlap`), when it would sit after a course that has not ended (422 + `medications.course.currentOpen`), on a one-time medication that already has one (422 + `medications.course.oneShot`), or when it ends before it starts (422 `medications.course.invalidRange`). + UpdateMedicationCourseRequest: + type: object + properties: + startsOn: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + endsOn: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + note: + anyOf: + - type: string + maxLength: 280 + - type: "null" + description: v1.40 — edit a course field by field. Same refusals as the create. MedicationListLayoutPutBody: type: object properties: @@ -32083,7 +32267,9 @@ components: 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 — medications.category.invalid, - medications.category.limitReached, medications.category.unknown, + medications.category.limitReached, medications.category.unknown, medications.course.currentOpen, + medications.course.invalid, medications.course.invalidRange, medications.course.notFound, + medications.course.oneShot, medications.course.overlap, medications.course.windowRequired, 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, @@ -35394,6 +35580,33 @@ components: - validUntil - payload additionalProperties: {} + courses: + type: array + items: + type: object + properties: + startsOn: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + endsOn: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + note: + anyOf: + - type: string + maxLength: 280 + - type: "null" + createdAt: + type: string + minLength: 1 + required: + - startsOn + - endsOn + additionalProperties: {} required: - name - dose @@ -44772,6 +44985,28 @@ components: - type: "null" description: v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in category, which a client localises from `category`. Read-only. + courses: + type: array + items: + $ref: "#/components/schemas/MedicationCourse" + description: v1.40 — every course, ascending by start. Empty for a medication taken continuously since creation. + Server-resolved; render it, do not recompute it. + courseCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + previousCourseEndedOn: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + description: v1.40 — the last day of the most recent course that has ended (answers 'when did I last take it'); null + when none has. + canStartCourse: + type: boolean + description: "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, + not one-time, has a course and every course has ended." externalSource: anyOf: - type: string @@ -44865,6 +45100,10 @@ components: - schedules - category - categoryLabel + - courses + - courseCount + - previousCourseEndedOn + - canStartCourse - externalSource - courseStatus - intakeActionable @@ -44887,6 +45126,51 @@ components: `custom:`, 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`. + MedicationCourse: + type: object + properties: + id: + type: string + startsOn: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: First day of the course, inclusive (`YYYY-MM-DD`). + endsOn: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + description: Last day, inclusive. NULL = open (only the latest course). + status: + type: string + enum: + - UPCOMING + - CURRENT + - ENDED + description: Where today, on the person's clock, sits in this course. + takenDoses: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Doses logged as taken whose slot falls on a day of this course. + note: + anyOf: + - type: string + - type: "null" + description: The person's note on this course. Encrypted at rest. + required: + - id + - startsOn + - endsOn + - status + - takenDoses + - note + additionalProperties: false + description: v1.40 (#1024) — one course of a medication. Courses never overlap and every course except the latest has + ended, so the medication's `startsOn`/`endsOn`/`courseStatus`/`intakeActionable` are always those of the latest + course. CreateMedicationResponse: type: object properties: @@ -45054,6 +45338,28 @@ components: - type: "null" description: v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in category, which a client localises from `category`. Read-only. + courses: + type: array + items: + $ref: "#/components/schemas/MedicationCourse" + description: v1.40 — every course, ascending by start. Empty for a medication taken continuously since creation. + Server-resolved; render it, do not recompute it. + courseCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + previousCourseEndedOn: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + description: v1.40 — the last day of the most recent course that has ended (answers 'when did I last take it'); null + when none has. + canStartCourse: + type: boolean + description: "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, + not one-time, has a course and every course has ended." courseStatus: type: string enum: @@ -45115,6 +45421,10 @@ components: - schedules - category - categoryLabel + - courses + - courseCount + - previousCourseEndedOn + - canStartCourse - courseStatus - intakeActionable - stockUnitsRemaining @@ -45242,6 +45552,143 @@ components: - data - error additionalProperties: false + MedicationCoursesResponse: + type: object + properties: + data: + type: object + properties: + courses: + type: array + items: + $ref: "#/components/schemas/MedicationCourse" + courseCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + previousCourseEndedOn: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + canStartCourse: + type: boolean + required: + - courses + - courseCount + - previousCourseEndedOn + - canStartCourse + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CreateMedicationCourseResponse: + type: object + properties: + data: + type: object + properties: + id: + type: string + startsOn: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + endsOn: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + required: + - id + - startsOn + - endsOn + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateMedicationCourseResponse: + type: object + properties: + data: + type: object + properties: + id: + type: string + startsOn: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + endsOn: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + required: + - id + - startsOn + - endsOn + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteMedicationCourseResponse: + type: object + properties: + data: + type: object + properties: + id: + type: string + deleted: + type: boolean + const: true + required: + - id + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationListLayoutResponse: type: object properties: diff --git a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql index 23d971b47..58d2e8ebb 100644 --- a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql +++ b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql @@ -40,3 +40,57 @@ CREATE UNIQUE INDEX IF NOT EXISTS "medication_category_labels_key_key" ON "medication_category_labels"("key"); CREATE INDEX IF NOT EXISTS "medication_category_labels_user_id_sort_order_idx" ON "medication_category_labels"("user_id", "sort_order"); + +-- Several courses per medication (#1024). A course is a calendar span the +-- person took the medication for; the medication's own starts_on / ends_on +-- stay and now project the latest course, so every reader that predates +-- courses keeps working unchanged. Idempotent. +CREATE TABLE IF NOT EXISTS "medication_courses" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "starts_on" DATE NOT NULL, + "ends_on" DATE, + "note_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "medication_courses_pkey" PRIMARY KEY ("id"), + CONSTRAINT "medication_courses_medication_id_fkey" + FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "medication_courses_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX IF NOT EXISTS "medication_courses_medication_id_starts_on_idx" + ON "medication_courses"("medication_id", "starts_on"); +CREATE INDEX IF NOT EXISTS "medication_courses_user_id_idx" + ON "medication_courses"("user_id"); + +-- Backfill: one course for every medication that has a window today. A course +-- needs a start, and a medication with only an end has always been read as +-- running from its creation day, so that is its start (or the end day, when +-- the end lies before the creation). A medication with +-- neither keeps no course: chronic since creation stays the absence of +-- courses. The medication columns are not touched, so the data is preserved +-- either way. The id is derived from the medication id, which makes a re-run +-- a no-op through ON CONFLICT. +-- +-- Reversal: dropping the table loses nothing while every medication has at +-- most one course (its columns still hold it). Once a second course exists, a +-- rollback keeps the current window on the medication and the earlier +-- courses' intakes in the ledger; only the earlier spans go. +INSERT INTO "medication_courses" + ("id", "medication_id", "user_id", "starts_on", "ends_on", "created_at", "updated_at") +SELECT + 'mc_' || m."id", + m."id", + m."user_id", + -- An end-only window that ends before the creation day (a backdated + -- record) starts on its end day instead, so no course ends before it starts. + COALESCE(m."starts_on", LEAST(m."created_at"::date, m."ends_on")), + m."ends_on", + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM "medications" m +WHERE m."starts_on" IS NOT NULL OR m."ends_on" IS NOT NULL +ON CONFLICT ("id") DO NOTHING; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 68d977d82..eb02f4ea7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -721,6 +721,7 @@ model User { intakeEvents MedicationIntakeEvent[] medicationPauseEras MedicationPauseEra[] medicationCategoryLabels MedicationCategoryLabel[] + medicationCourses MedicationCourse[] apiTokens ApiToken[] // v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted // authorization for an MCP client, audience-bound to health:read/health:write. @@ -2797,6 +2798,10 @@ model Medication { /// v1.40 — the clinical category the person filed this medication under /// (one row at most; absence reads as OTHER). categoryAssignment MedicationCategoryAssignment? + /// v1.40 (#1024) — the medication's courses. Empty = chronic since + /// creation. `startsOn`/`endsOn` above project the latest one, written only + /// by `src/lib/medications/courses.ts`. + courses MedicationCourse[] user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2861,6 +2866,36 @@ model MedicationCategoryLabel { @@map("medication_category_labels") } +/// v1.40 (#1024) — one course of a medication: a calendar span the person +/// took it for (Tamiflu for a week now and then). Courses never overlap, and +/// every course but the latest-starting one has ended, so the latest course is +/// always the one `Medication.startsOn/endsOn` project (see +/// `src/lib/medications/course-window.ts`). A course carries no schedule or +/// dose of its own: a dose that changes with a course is a schedule revision +/// dated to its start. A one-time medication has at most one course. +model MedicationCourse { + id String @id @default(cuid()) + medicationId String @map("medication_id") + /// Denormalised owner, so the wipe and the sharing scope address the row + /// directly. Always the medication's owner. + userId String @map("user_id") + /// First day, inclusive, on the person's clock. + startsOn DateTime @map("starts_on") @db.Date + /// Last day, inclusive. NULL = open (only the latest course may be open). + endsOn DateTime? @map("ends_on") @db.Date + /// Why this course, in the person's words. AES-256-GCM at rest. + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([medicationId, startsOn]) + @@index([userId]) + @@map("medication_courses") +} + /// v1.28 — the user's explicit override of a medication's efficacy target, /// the ONE thing the "Wirkung" view persists. Class→outcome resolution /// (ATC-prefix → name inference) is computed each read from the closed diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 4dfee834f..984291c64 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -664,6 +664,14 @@ async function main() { prisma.medicationDoseChange, ), ); + // v1.40 (#1024) — the note on a medication course. + results.push( + await rotateBytesColumn( + "MedicationCourse", + "noteEncrypted", + prisma.medicationCourse, + ), + ); results.push( await rotateBytesColumn( "MedicationInventoryItem", diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 03fe54a8d..0aa719d4b 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -475,6 +475,13 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "permanent", confirm: ["AlertDialog"], }, + { + file: "components/medications/sections/courses-section.tsx", + destroys: + "one course of a medication (its dates and note); the intakes logged in it stay, and deleting the only course makes the medication continuous again", + recovery: "permanent", + confirm: ["AlertDialog"], + }, { file: "components/medications/use-medication-intake.ts", destroys: "an intake event recorded seconds ago", diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index d3e4e7ecc..349245bea 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -270,6 +270,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // store, the same data-layer reasoning. "src/app/api/medications/categories/route.ts", "src/app/api/medications/categories/[key]/route.ts", + // v1.40 (#1024) — a medication's courses, same again. + "src/app/api/medications/[id]/courses/route.ts", + "src/app/api/medications/[id]/courses/[courseId]/route.ts", "src/app/api/medications/compliance/route.ts", "src/app/api/medications/intake/route.ts", "src/app/api/medications/intake/bulk/route.ts", diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 87cae0237..81ee918fd 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -442,6 +442,7 @@ describe("POST /api/export/health-record — the selection reaches every format" schedules: [], scheduleRevisions: [], pauseEras: [], + courses: [], doseChanges: [], intakeEvents: [], }; diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 61b69805c..b1d56040a 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -28,6 +28,7 @@ import { import { pairByTimestamp } from "@/lib/analytics/correlations"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, calculateCompliance, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, @@ -118,6 +119,8 @@ export const GET = apiHandler(async () => { scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }), probeRollupCoverage(user.id), diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index da41187b3..f15b92687 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -17,6 +17,7 @@ import { } from "@/lib/analytics/correlations"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, calculateCompliance, expectsDoses, lastNonSkippedTakenAt, @@ -393,6 +394,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) { scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }); // A medication with no schedule expects no dose, and `calculateCompliance` diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 3bdc698f4..ed1e5b8bf 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -49,6 +49,14 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ dayKeyForScheduledFor: vi.fn(() => "2026-06-10"), recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), })); +// v1.40 (#1024) — the course writer and reader run against their own +// integration suite; here they answer "no course, nothing refused". +vi.mock("@/lib/medications/courses", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCourseFields: vi.fn(async () => new Map()), + checkCurrentWindow: vi.fn(async () => null), + setCurrentWindow: vi.fn(async () => undefined), +})); vi.mock("@/lib/medication-category", () => ({ deleteMedicationCategory: vi.fn().mockResolvedValue(undefined), getMedicationCategories: vi.fn().mockResolvedValue({}), @@ -72,6 +80,7 @@ vi.mock("next/headers", () => ({ import { PUT } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { resolveCourseFields } from "@/lib/medications/courses"; import { getMedicationCategories, isAssignableCategory, @@ -102,7 +111,9 @@ 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. + // v1.40 — the category and course helpers' defaults, which + // `resetAllMocks` clears. + vi.mocked(resolveCourseFields).mockResolvedValue(new Map()); vi.mocked(resolveMedicationCategories).mockResolvedValue({}); vi.mocked(isAssignableCategory).mockResolvedValue(true); vi.mocked(setMedicationCategory).mockImplementation( diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 4831388da..5a11e30ba 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; @@ -74,6 +75,8 @@ export const GET = apiHandler( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }); // Privacy gate — same sealed 404 for "absent" and "not yours". diff --git a/src/app/api/medications/[id]/courses/[courseId]/route.ts b/src/app/api/medications/[id]/courses/[courseId]/route.ts new file mode 100644 index 000000000..e4b99dddc --- /dev/null +++ b/src/app/api/medications/[id]/courses/[courseId]/route.ts @@ -0,0 +1,119 @@ +/** + * v1.40 (#1024) — edit or delete one course. Both re-project the + * medication's window in the same transaction. Deleting the only course + * makes the medication chronic again; the UI asks first. Owner-only. + */ +import { NextRequest } from "next/server"; + +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { annotate } from "@/lib/logging/context"; +import { + CourseWriteError, + courseRefusalResponse, + deleteCourse, + updateCourse, +} from "@/lib/medications/courses"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { updateMedicationCourseSchema } from "@/lib/validations/medication"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string; courseId: string }> }; + +function refused(err: unknown): Response { + if (err instanceof CourseWriteError) { + const r = courseRefusalResponse(err.refusal); + annotate({ meta: { "medication.course.refused": err.refusal } }); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + throw err; +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, courseId } = await params; + + const { data: raw, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = updateMedicationCourseSchema.safeParse(raw); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "medications.course.invalid", + }); + } + + try { + const updated = await updateCourse({ + userId: user.id, + medicationId: id, + courseId, + timeZone: user.timezone || DEFAULT_TIMEZONE, + startsOn: parsed.data.startsOn, + endsOn: parsed.data.endsOn, + note: parsed.data.note, + }); + invalidateUserMedications(user.id, { evict: true }); + await auditLog("medication.course.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + courseId, + fields: Object.keys(parsed.data), + }, + }); + annotate({ + action: { name: "medication.course.update" }, + meta: { medicationId: id }, + }); + return apiSuccess({ + id: updated.id, + startsOn: dayKeyOfDate(updated.startsOn), + endsOn: updated.endsOn ? dayKeyOfDate(updated.endsOn) : null, + }); + } catch (err) { + return refused(err); + } + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, courseId } = await params; + try { + await deleteCourse({ + userId: user.id, + medicationId: id, + courseId, + timeZone: user.timezone || DEFAULT_TIMEZONE, + }); + } catch (err) { + return refused(err); + } + invalidateUserMedications(user.id, { evict: true }); + await auditLog("medication.course.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, courseId }, + }); + annotate({ + action: { name: "medication.course.delete" }, + meta: { medicationId: id }, + }); + return apiSuccess({ id: courseId, deleted: true }); + }, +); diff --git a/src/app/api/medications/[id]/courses/route.ts b/src/app/api/medications/[id]/courses/route.ts new file mode 100644 index 000000000..1af87a726 --- /dev/null +++ b/src/app/api/medications/[id]/courses/route.ts @@ -0,0 +1,125 @@ +/** + * v1.40 (#1024) — a medication's courses. + * + * GET /api/medications/{id}/courses — the resolved course fields, the same + * ones the list and detail reads carry. + * POST /api/medications/{id}/courses — start a course (`{ startsOn, + * endsOn?, note? }`). The medication's `startsOn`/`endsOn` are + * rewritten to the latest course in the same transaction, so reminders + * and the dose actions follow without another write. + * + * Owner-only for now: a delegate edits the current window through the + * medication's own PUT, as before. + */ +import { NextRequest } from "next/server"; + +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { annotate } from "@/lib/logging/context"; +import { + CourseWriteError, + courseRefusalResponse, + createCourse, + resolveCourseFields, +} from "@/lib/medications/courses"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { createMedicationCourseSchema } from "@/lib/validations/medication"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + const medication = await prisma.medication.findFirst({ + where: { id, userId: user.id }, + select: { id: true, active: true, oneShot: true }, + }); + if (!medication) return apiError("Medication not found", 404); + const fields = ( + await resolveCourseFields( + [medication], + new Date(), + user.timezone || DEFAULT_TIMEZONE, + ) + ).get(id); + return apiSuccess(fields); + }, +); + +export const POST = apiHandler( + withIdempotency<[NextRequest, RouteParams]>(postCourse), +); + +async function postCourse( + request: NextRequest, + { params }: RouteParams, +): Promise { + const { user } = await requireAuth(); + const { id } = await params; + + const { data: raw, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createMedicationCourseSchema.safeParse(raw); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "medications.course.invalid", + }); + } + + try { + const created = await createCourse({ + userId: user.id, + medicationId: id, + timeZone: user.timezone || DEFAULT_TIMEZONE, + startsOn: parsed.data.startsOn, + endsOn: parsed.data.endsOn ?? null, + note: parsed.data.note, + }); + invalidateUserMedications(user.id, { evict: true }); + await auditLog("medication.course.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + courseId: created.id, + startsOn: dayKeyOfDate(created.startsOn), + endsOn: created.endsOn ? dayKeyOfDate(created.endsOn) : null, + }, + }); + annotate({ + action: { name: "medication.course.create" }, + meta: { medicationId: id }, + }); + return apiSuccess( + { + id: created.id, + startsOn: dayKeyOfDate(created.startsOn), + endsOn: created.endsOn ? dayKeyOfDate(created.endsOn) : null, + }, + 201, + ); + } catch (err) { + if (err instanceof CourseWriteError) { + const r = courseRefusalResponse(err.refusal); + annotate({ meta: { "medication.course.refused": err.refusal } }); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + throw err; + } +} diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index ad3e6c63b..de6424398 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -19,6 +19,12 @@ import { setMedicationCategory, } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; +import { + checkCurrentWindow, + courseRefusalResponse, + resolveCourseFields, + setCurrentWindow, +} from "@/lib/medications/courses"; import { schedulesMateriallyDiffer, toRevisionPayloadEntry, @@ -238,6 +244,15 @@ export const GET = apiHandler( Number(medication.unitsPerDose), ); + // v1.40 (#1024) — the courses, resolved on the server. + const courseFields = ( + await resolveCourseFields( + [medication], + now, + user.timezone || DEFAULT_TIMEZONE, + ) + ).get(id); + return apiSuccess({ ...medication, unitsPerDose: Number(medication.unitsPerDose), @@ -246,6 +261,7 @@ export const GET = apiHandler( ...scheduleWireFields(medication.trackIntake, schedulesDto), category, categoryLabel, + ...courseFields, nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, // v1.39.4 (#1040) — see `resolveIntakeActionability`. @@ -294,6 +310,9 @@ export const PUT = apiHandler( name: true, dose: true, endsOn: true, + // v1.40 (#1024) — the course check below needs the stored window. + startsOn: true, + oneShot: true, _count: { select: { schedules: true } }, }, }); @@ -494,6 +513,34 @@ export const PUT = apiHandler( // means the dose is the start date; endsOn auto-matches. const normalisedEndsOn = oneShot === true && startsOn ? startsOn : endsOn; + // v1.40 (#1024) — the window a PUT sets is the latest course. Check it + // against the other courses BEFORE the row changes, so a refused window + // leaves the medication as it was. + const touchesWindow = + startsOn !== undefined || + normalisedEndsOn !== undefined || + oneShot !== undefined; + const nextWindow = { + startsOn: startsOn !== undefined ? startsOn : existing.startsOn, + endsOn: + normalisedEndsOn !== undefined ? normalisedEndsOn : existing.endsOn, + }; + const courseTz = user.timezone || DEFAULT_TIMEZONE; + if (touchesWindow) { + const refusal = await checkCurrentWindow({ + userId: user.id, + medicationId: id, + timeZone: courseTz, + ...nextWindow, + oneShot: oneShot ?? existing.oneShot, + createdAt: existing.createdAt, + }); + if (refusal) { + const r = courseRefusalResponse(refusal); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + } + const pausedAtPatch = active === undefined ? {} @@ -1002,6 +1049,18 @@ export const PUT = apiHandler( } } + // v1.40 (#1024) — write the window onto the latest course and project it + // back, then serve the projected row. + if (touchesWindow) { + const projected = await setCurrentWindow({ + userId: user.id, + medicationId: id, + timeZone: courseTz, + ...nextWindow, + }); + if (projected) medication = { ...medication, ...projected }; + } + if (category !== undefined) await setMedicationCategory(id, category); const resolvedCategory = (await resolveMedicationCategories([id]))[id]; const normalizedCategory = resolvedCategory?.category ?? "OTHER"; @@ -1068,6 +1127,10 @@ export const PUT = apiHandler( ), category: normalizedCategory, categoryLabel: resolvedCategory?.categoryLabel ?? null, + // v1.40 (#1024) — the courses, as every medication read carries them. + ...(await resolveCourseFields([medication], new Date(), courseTz)).get( + id, + ), }); }, ); diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 6f58b809e..a60495d7e 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -51,6 +51,14 @@ vi.mock("@/lib/cache/server-cache", () => ({ caches: { medicationsList: {} }, __resetAllCachesForTests: vi.fn(), })); +// v1.40 (#1024) — the course writer and reader run against their own +// integration suite; here they answer "no course, nothing refused". +vi.mock("@/lib/medications/courses", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCourseFields: vi.fn(async () => new Map()), + checkCurrentWindow: vi.fn(async () => null), + setCurrentWindow: vi.fn(async () => undefined), +})); vi.mock("@/lib/medication-category", () => ({ getMedicationCategories: vi.fn().mockResolvedValue({}), resolveMedicationCategories: vi.fn().mockResolvedValue({}), @@ -83,6 +91,7 @@ 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 { resolveCourseFields } from "@/lib/medications/courses"; import { getMedicationCategories, isAssignableCategory, @@ -106,7 +115,9 @@ 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. + // v1.40 — the category and course helpers' defaults, which + // `resetAllMocks` clears. + vi.mocked(resolveCourseFields).mockResolvedValue(new Map()); vi.mocked(resolveMedicationCategories).mockResolvedValue({}); vi.mocked(isAssignableCategory).mockResolvedValue(true); vi.mocked(setMedicationCategory).mockImplementation( diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index eee5e5f97..59d17ee03 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -1,5 +1,6 @@ import pLimit from "p-limit"; +import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -59,6 +60,8 @@ export const GET = apiHandler(async () => { scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, orderBy: { createdAt: "desc" }, }); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 8f93617a0..4ebc3e955 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -10,6 +10,16 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { + courseRefusalResponse, + resolveCourseFields, +} from "@/lib/medications/courses"; +import { + dateOfDayKey, + dayKeyOfDate, + validateCourses, +} from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; import { createMedicationSchema, isCustomMedicationCategoryKey, @@ -283,6 +293,31 @@ async function postMedication(request: NextRequest): Promise { const normalisedEndsOn = oneShot === true && startsOn ? startsOn : (endsOn ?? undefined); + // v1.40 (#1024) — a medication created with a window starts with one + // course; the row projects it. An end without a start has always meant + // "running since creation", so the course starts today (or on the end day, + // when that lies earlier). No window: no course, chronic since creation. + let initialCourse: { startsOn: Date; endsOn: Date | null } | null = null; + if (startsOn || normalisedEndsOn) { + const todayKey = userDayKey(new Date(), user.timezone || DEFAULT_TIMEZONE); + const endKey = normalisedEndsOn ? dayKeyOfDate(normalisedEndsOn) : null; + initialCourse = { + startsOn: + startsOn ?? + dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey), + endsOn: normalisedEndsOn ?? null, + }; + const refusal = validateCourses( + [initialCourse], + todayKey, + oneShot === true, + ); + if (refusal) { + const r = courseRefusalResponse(refusal); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + } + const createMedication = () => prisma.medication.create({ data: { @@ -323,6 +358,17 @@ async function postMedication(request: NextRequest): Promise { // v1.5 scheduling primitives — pass-through when supplied. ...(startsOn !== undefined && { startsOn }), ...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }), + ...(initialCourse && { + startsOn: initialCourse.startsOn, + endsOn: initialCourse.endsOn, + courses: { + create: { + userId: user.id, + startsOn: initialCourse.startsOn, + endsOn: initialCourse.endsOn, + }, + }, + }), ...(oneShot !== undefined && { oneShot }), // v1.16.11 — as-needed flag, field-by-field. An asNeeded create // carries an empty `scheduleInputs`, so the nested create below @@ -468,6 +514,14 @@ async function postMedication(request: NextRequest): Promise { ), category: normalizedCategory, categoryLabel, + // v1.40 (#1024) — the courses, as every medication read carries them. + ...( + await resolveCourseFields( + [medication], + new Date(), + user.timezone || DEFAULT_TIMEZONE, + ) + ).get(medication.id), }, 201, ); diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index d9331fdcc..7d13852ec 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -1,5 +1,6 @@ "use client"; +import type { MedicationCourseFields } from "@/components/medications/course-fields"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -127,6 +128,11 @@ interface Medication { intakeActionable?: boolean; /** v1.39.4 (#1040) — where today sits in the course. */ courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; + /** v1.40 (#1024) — the server-resolved course fields. */ + courses?: MedicationCourseFields["courses"]; + courseCount?: number; + previousCourseEndedOn?: string | null; + canStartCourse?: boolean; /** v1.9.0 — optional WHO ATC classification code for the FHIR export. */ atcCode?: string | null; /** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */ diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 2a5105fb1..eb0951adf 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -230,7 +230,12 @@ export function ClinicianView({ /> {/* ── medications ──────────────────────────────────────── */} - + string; }) { const medications = scope.admits("MEDICATION_LIST") ? (report.medications ?? []) @@ -344,15 +355,33 @@ export function MedicationsSection({ ? `${Math.round((comp.taken / comp.total) * 100)}%` : null; return ( - + + + {/* v1.40 (#1024) — one row per course inside the window. */} + {(med.courses ?? []).map((course, index) => ( + + ))} + ); })} {complianceEntries diff --git a/src/components/medications/card-parts/medication-state-badges.tsx b/src/components/medications/card-parts/medication-state-badges.tsx index 6501006f3..cccd86ea1 100644 --- a/src/components/medications/card-parts/medication-state-badges.tsx +++ b/src/components/medications/card-parts/medication-state-badges.tsx @@ -1,6 +1,7 @@ import { Badge } from "@/components/ui/badge"; import { formatDateTime } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { formatDate } from "@/lib/date-format"; +import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; interface MedicationStateBadgesProps { notificationsEnabled: boolean; @@ -10,6 +11,13 @@ interface MedicationStateBadgesProps { recordOnly?: boolean; /** v1.39.4 (#1040) — the course's end date has passed. */ courseEnded?: boolean; + /** + * v1.40 (#1024) — the server's `previousCourseEndedOn`: an ended course + * names its last day ("Last course ended 16 Jun"). + */ + lastCourseEndedOn?: string | null; + /** v1.40 (#1024) — the running course's number, when there are several. */ + courseNumber?: number | null; } /** @@ -24,8 +32,11 @@ export function MedicationStateBadges({ pausedAt, recordOnly = false, courseEnded = false, + lastCourseEndedOn = null, + courseNumber = null, }: MedicationStateBadgesProps) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); + const dateFormatPref = useDateFormatPreference(); return ( <> @@ -44,7 +55,24 @@ export function MedicationStateBadges({ className="text-xs" data-slot="medication-course-ended-badge" > - {t("medications.courseEndedBadge")} + {lastCourseEndedOn + ? t("medications.course.endedOn", { + date: formatDate( + new Date(lastCourseEndedOn), + dateFormatPref, + locale, + ), + }) + : t("medications.courseEndedBadge")} + + )} + {!courseEnded && courseNumber !== null && ( + + {t("medications.course.nth", { n: courseNumber })} )} {!notificationsEnabled && !recordOnly && !courseEnded && ( diff --git a/src/components/medications/course-fields.ts b/src/components/medications/course-fields.ts new file mode 100644 index 000000000..bf48a5d51 --- /dev/null +++ b/src/components/medications/course-fields.ts @@ -0,0 +1,34 @@ +/** + * v1.40 (#1024) — the course fields every medication read carries, as the + * client consumes them. Server-resolved: render, never recompute. + */ +export interface MedicationCourseWire { + id: string; + /** `YYYY-MM-DD`, inclusive. */ + startsOn: string; + /** `YYYY-MM-DD`, inclusive; null = open. */ + endsOn: string | null; + status: "UPCOMING" | "CURRENT" | "ENDED"; + takenDoses: number; + note: string | null; +} + +export interface MedicationCourseFields { + courses?: MedicationCourseWire[]; + courseCount?: number; + previousCourseEndedOn?: string | null; + canStartCourse?: boolean; +} + +/** + * The 1-based number of the course running or about to run, when the + * medication has more than one; null otherwise (nothing to number). + */ +export function currentCourseNumber( + fields: MedicationCourseFields, +): number | null { + const courses = fields.courses ?? []; + if (courses.length < 2) return null; + const index = courses.findIndex((c) => c.status !== "ENDED"); + return index === -1 ? null : index + 1; +} diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 6eeae397b..63a134774 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -31,6 +31,8 @@ * administration. */ +import type { MedicationCourseWire } from "@/components/medications/course-fields"; +import { CoursesRow } from "@/components/medications/sections/courses-section"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useCallback, @@ -174,6 +176,9 @@ export interface MedicationDetailSnapshot { trackIntake?: boolean; /** v1.39.4 (#1040) — where today sits in the course (server-resolved). */ courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; + /** v1.40 (#1024) — the server-resolved courses. */ + courses?: MedicationCourseWire[]; + canStartCourse?: boolean; recordedSchedules?: ScheduleSnapshot[]; schedules: ScheduleSnapshot[]; } @@ -907,6 +912,16 @@ export function MedicationDetailTabs({ active={medication.active} />
+ {/* v1.40 (#1024) — the courses: when it was taken, and a new + course once the last one has ended. */} +
+ +
{/* The structural editor (the create/edit wizard) — name, class, cadence kind, schedules. Replaces the former hero "Vollständig bearbeiten" button, which read as "edit this diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 70a406226..dcd2bdc92 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -1,5 +1,9 @@ "use client"; +import { + currentCourseNumber, + type MedicationCourseFields, +} from "@/components/medications/course-fields"; import { useEffect, useReducer, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -144,6 +148,11 @@ export interface Glp1Medication { intakeActionable?: boolean; /** v1.39.4 (#1040) — where today sits in the course. */ courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; + /** v1.40 (#1024) — the server-resolved course fields. */ + courses?: MedicationCourseFields["courses"]; + courseCount?: number; + previousCourseEndedOn?: string | null; + canStartCourse?: boolean; schedules: ScheduleLite[]; } @@ -456,6 +465,8 @@ export function Glp1MedicationCard({ pausedAt={medication.pausedAt} recordOnly={medication.trackIntake === false} courseEnded={medication.courseStatus === "ENDED"} + lastCourseEndedOn={medication.previousCourseEndedOn ?? null} + courseNumber={currentCourseNumber(medication)} /> ); diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index edd97d6bb..1f45a5d9b 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -1,5 +1,9 @@ "use client"; +import { + currentCourseNumber, + type MedicationCourseFields, +} from "@/components/medications/course-fields"; import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -129,6 +133,11 @@ interface Medication { intakeActionable?: boolean; /** v1.39.4 (#1040) — where today sits in the course. */ courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; + /** v1.40 (#1024) — the server-resolved course fields. */ + courses?: MedicationCourseFields["courses"]; + courseCount?: number; + previousCourseEndedOn?: string | null; + canStartCourse?: boolean; schedules: Schedule[]; } @@ -399,6 +408,8 @@ export function MedicationCard({ pausedAt={medication.pausedAt} recordOnly={medication.trackIntake === false} courseEnded={medication.courseStatus === "ENDED"} + lastCourseEndedOn={medication.previousCourseEndedOn ?? null} + courseNumber={currentCourseNumber(medication)} /> ); diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index 06c07558a..56f11405c 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -1,5 +1,6 @@ "use client"; +import type { MedicationCourseFields } from "@/components/medications/course-fields"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useEffect, useReducer, useState } from "react"; import Link from "next/link"; @@ -33,7 +34,12 @@ import { reduceCurrentWindowStatus } from "@/lib/medications/window-status"; import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label"; import { formatTime } from "@/lib/format"; import { formatTimeWindowRange } from "@/lib/time-window-format"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { formatDate } from "@/lib/date-format"; +import { + useDateFormatPreference, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; import { formatDose } from "@/lib/medications/format-dose"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -104,6 +110,11 @@ export interface TableMedication { */ intakeActionable?: boolean; courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; + /** v1.40 (#1024) — the server-resolved course fields. */ + courses?: MedicationCourseFields["courses"]; + courseCount?: number; + previousCourseEndedOn?: string | null; + canStartCourse?: boolean; /** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */ stockDosesRemaining?: number | null; /** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */ @@ -412,6 +423,7 @@ function MedicationTableRowItem({ highlighted = false, }: MedicationTableRowItemProps) { const { t, locale } = useTranslations(); + const dateFormatPref = useDateFormatPreference(); const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); @@ -542,7 +554,15 @@ function MedicationTableRowItem({ className="text-muted-foreground" data-slot="medication-table-course-ended-marker" > - {t("medications.courseEndedBadge")} + {medication.previousCourseEndedOn + ? t("medications.course.endedOn", { + date: formatDate( + new Date(medication.previousCourseEndedOn), + dateFormatPref, + locale, + ), + }) + : t("medications.courseEndedBadge")} ); } else if (medication.asNeeded) { diff --git a/src/components/medications/sections/courses-section.tsx b/src/components/medications/sections/courses-section.tsx new file mode 100644 index 000000000..56fc71fe7 --- /dev/null +++ b/src/components/medications/sections/courses-section.tsx @@ -0,0 +1,382 @@ +"use client"; + +/** + * v1.40 (#1024) — the courses of a medication, in the detail page's + * Lifecycle group: one row per course (dates, where today sits, doses + * taken), "Start a new course" once every course has ended, and edit / + * delete per course. The rows follow the Lifecycle rows beside them (title + + * muted helper, actions at the trailing edge); the editor is a + * ResponsiveSheet with DateFields and inline refusals. + * + * Every figure is the server's (`courses`, `canStartCourse`); this file + * renders them and computes nothing. + */ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-field"; +import { FieldError } from "@/components/forms/field-error"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import type { MedicationCourseWire } from "@/components/medications/course-fields"; +import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { formatDate } from "@/lib/date-format"; +import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; +import { invalidateMedicationReads } from "@/lib/query-keys"; + +/** The refusal codes the course routes answer, mapped to their copy. */ +const REFUSAL_KEYS: Record = { + "medications.course.overlap": "medications.course.error.overlap", + "medications.course.currentOpen": "medications.course.error.currentOpen", + "medications.course.oneShot": "medications.course.error.oneShot", + "medications.course.invalidRange": "medications.course.error.invalidRange", +}; + +function useCourseDate() { + const { locale } = useTranslations(); + const pref = useDateFormatPreference(); + return (key: string) => formatDate(new Date(key), pref, locale); +} + +function CourseEditor({ + medicationId, + course, + open, + onOpenChange, +}: { + medicationId: string; + /** Absent → start a new course. */ + course?: MedicationCourseWire; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [startsOn, setStartsOn] = useState(course?.startsOn ?? ""); + const [endsOn, setEndsOn] = useState(course?.endsOn ?? ""); + const [note, setNote] = useState(course?.note ?? ""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const rangeInvalid = startsOn !== "" && endsOn !== "" && endsOn < startsOn; + + async function save() { + if (!startsOn || rangeInvalid || saving) return; + setSaving(true); + setError(null); + const body = { + startsOn, + endsOn: endsOn === "" ? null : endsOn, + note: note.trim() === "" ? null : note.trim(), + }; + try { + if (course) { + await apiPatch( + `/api/medications/${medicationId}/courses/${course.id}`, + body, + ); + } else { + await apiPost(`/api/medications/${medicationId}/courses`, body); + } + await invalidateMedicationReads(queryClient); + onOpenChange(false); + } catch (err) { + const code = + err instanceof ApiError + ? (err.meta?.errorCode as string | undefined) + : undefined; + setError( + t( + code && REFUSAL_KEYS[code] + ? REFUSAL_KEYS[code] + : "medications.course.error.generic", + ), + ); + } finally { + setSaving(false); + } + } + + return ( + + + + + } + > +
+
+
+ + +
+
+ + + +
+
+
+ + setNote(e.target.value)} + /> +
+ +
+
+ ); +} + +export function CoursesRow({ + medicationId, + courses, + canStartCourse, + canEdit, +}: { + medicationId: string; + courses: MedicationCourseWire[]; + canStartCourse: boolean; + /** Course writes are the owner's; a shared record shows the list only. */ + canEdit: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const date = useCourseDate(); + const [editing, setEditing] = useState(null); + const [starting, setStarting] = useState(false); + const [deleting, setDeleting] = useState(null); + const [deleteBusy, setDeleteBusy] = useState(false); + + async function confirmDelete(course: MedicationCourseWire) { + setDeleteBusy(true); + try { + await apiDelete(`/api/medications/${medicationId}/courses/${course.id}`); + await invalidateMedicationReads(queryClient); + setDeleting(null); + } catch { + toast.error(t("medications.course.error.generic")); + } finally { + setDeleteBusy(false); + } + } + + const statusLabel = (status: MedicationCourseWire["status"]) => + status === "CURRENT" + ? t("medications.course.status.current") + : status === "UPCOMING" + ? t("medications.course.status.upcoming") + : t("medications.course.status.ended"); + + return ( +
+
+
+

+ {t("medications.course.title")} +

+

+ {t("medications.course.helper")} +

+
+ {canEdit && canStartCourse && ( + + )} +
+ + {courses.length === 0 ? ( +

+ {t("medications.course.none")} +

+ ) : ( +
    + {courses.map((course, index) => ( +
  • +
    +

    + {course.endsOn + ? t("medications.course.range", { + start: date(course.startsOn), + end: date(course.endsOn), + }) + : t("medications.course.since", { + start: date(course.startsOn), + })} +

    +

    + {t("medications.course.meta", { + n: index + 1, + status: statusLabel(course.status), + taken: course.takenDoses, + })} +

    + {course.note && ( +

    {course.note}

    + )} +
    + {canEdit && ( + <> + + + + )} +
  • + ))} +
+ )} + + {starting && ( + + )} + {editing && ( + { + if (!open) setEditing(null); + }} + /> + )} + + { + if (!open) setDeleting(null); + }} + > + + + + {t("medications.course.deleteTitle")} + + + {courses.length === 1 + ? t("medications.course.deleteLastConfirm") + : t("medications.course.deleteBody")} + + + + {t("common.cancel")} + { + e.preventDefault(); + if (deleting) void confirmDelete(deleting); + }} + > + {t("medications.course.delete")} + + + + +
+ ); +} diff --git a/src/lib/ai/coach/snapshot-blocks/compliance-block.ts b/src/lib/ai/coach/snapshot-blocks/compliance-block.ts index 763822efc..ebccd6a2e 100644 --- a/src/lib/ai/coach/snapshot-blocks/compliance-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/compliance-block.ts @@ -24,6 +24,7 @@ import { } from "@/lib/analytics/compliance"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { annotate } from "@/lib/logging/context"; +import { courseStatusOn } from "@/lib/medications/course-window"; import { isoWeekKey, tzDayKey, tzWeekday } from "../snapshot-series"; import type { CoachProvenance, @@ -55,6 +56,22 @@ interface ComplianceBlockContext { registerBlock: (key: string, source: CoachScopeSource) => void; } +function courseSummary( + meds: readonly ComplianceBlockMedication[], + now: Date, + userTz: string, +): { courses?: { takenInCourses: number; noCourseRunning: number } } { + const todayKey = tzDayKey(now, userTz); + const several = meds.filter((m) => (m.courses?.length ?? 0) >= 2); + if (several.length === 0) return {}; + const idle = several.filter((m) => + (m.courses ?? []).every((c) => courseStatusOn(c, todayKey) !== "CURRENT"), + ).length; + return { + courses: { takenInCourses: several.length, noCourseRunning: idle }, + }; +} + export function buildComplianceBlock( ctx: Readonly, ): void { @@ -183,6 +200,11 @@ export function buildComplianceBlock( ? Math.round((complianceTaken / complianceDenominator) * 100) : null, timeline: { recent: recentRows, weekly: weeklyRows }, + // v1.40 (#1024) — how many medications are taken in separate courses, + // and how many of those have no course running today. Their days + // between courses expect no dose, so a quiet stretch there is not a + // lapse; the rate above already leaves those days out. + ...courseSummary(complianceMeds, now, userTz), }; metrics.add("compliance"); counts.compliance = countable.length; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 666ae143c..d3115a4e7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -56,7 +56,10 @@ import type { ReferenceMetric } from "@/lib/reference-ranges"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { resolveModuleMap } from "@/lib/modules/gate"; import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate"; -import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; +import { + COURSES_COMPLIANCE_SELECT, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; import type { BaselineProfile } from "@/lib/insights/derived"; import { toProfileSex } from "@/lib/profile/sex"; import { @@ -650,6 +653,8 @@ async function buildCoachSnapshotImpl( }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, intakeEvents: { // Tombstoned intake rows must never reach the Coach snapshot. where: { deletedAt: null, scheduledFor: { gte: cutoff } }, diff --git a/src/lib/analytics/__tests__/compliance-courses.test.ts b/src/lib/analytics/__tests__/compliance-courses.test.ts new file mode 100644 index 000000000..5eae5a23d --- /dev/null +++ b/src/lib/analytics/__tests__/compliance-courses.test.ts @@ -0,0 +1,118 @@ +/** + * v1.40 (#1024) — a medication taken in two courses with a gap between them + * expects doses only inside the courses. Every dose of both courses is taken, + * so the honest rate is 100 %; a minter that expanded across the gap would + * read the gap's days as missed and report far less. + */ +import { describe, expect, it } from "vitest"; + +import { + buildComplianceMedicationContext, + calculateCompliance, + expectedSlotsBetween, + type ComplianceSchedule, + type IntakeEvent, +} from "@/lib/analytics/compliance"; +import { dateOfDayKey } from "@/lib/medications/course-window"; + +const TZ = "Europe/Berlin"; +const NOW = new Date("2026-06-20T18:00:00.000Z"); +const DAILY: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }, +]; +const COURSES = [ + { startsOn: dateOfDayKey("2026-04-01"), endsOn: dateOfDayKey("2026-04-07") }, + { startsOn: dateOfDayKey("2026-06-10"), endsOn: dateOfDayKey("2026-06-16") }, +]; + +function dayKeys(from: string, to: string): string[] { + const out: string[] = []; + for ( + let d = dateOfDayKey(from); + d.getTime() <= dateOfDayKey(to).getTime(); + d = new Date(d.getTime() + 86_400_000) + ) { + out.push(d.toISOString().slice(0, 10)); + } + return out; +} + +// 08:00 Berlin (CEST) is 06:00 UTC; taken five minutes in. +const EVENTS: IntakeEvent[] = [ + ...dayKeys("2026-04-01", "2026-04-07"), + ...dayKeys("2026-06-10", "2026-06-16"), +].map((day) => ({ + scheduledFor: new Date(`${day}T06:00:00.000Z`), + takenAt: new Date(`${day}T06:05:00.000Z`), + skipped: false, +})); + +const MEDICATION = { + // The row projects the latest course. + startsOn: COURSES[1].startsOn, + endsOn: COURSES[1].endsOn, + oneShot: false, + createdAt: new Date("2026-03-15T10:00:00.000Z"), +}; + +describe("compliance across two courses", () => { + it("expects a dose on every course day and none in the gap", () => { + const ctx = buildComplianceMedicationContext( + { ...MEDICATION, courses: COURSES }, + EVENTS[EVENTS.length - 1].takenAt, + TZ, + ); + const slots = expectedSlotsBetween( + DAILY, + new Date("2026-03-22T00:00:00.000Z"), + NOW, + ctx, + EVENTS, + ); + expect(slots).toHaveLength(14); + }); + + it("reports the rate of the two courses alone over a 90-day window", () => { + const ctx = buildComplianceMedicationContext( + { ...MEDICATION, courses: COURSES }, + EVENTS[EVENTS.length - 1].takenAt, + TZ, + ); + const result = calculateCompliance( + EVENTS, + DAILY, + 90, + MEDICATION.createdAt, + { + now: NOW, + medicationContext: ctx, + }, + ); + expect(result.missed).toBe(0); + expect(result.taken).toBe(14); + expect(result.rate).toBe(100); + }); + + it("keeps one course on the behaviour every medication had before", () => { + const ctx = buildComplianceMedicationContext( + { ...MEDICATION, courses: [COURSES[1]] }, + EVENTS[EVENTS.length - 1].takenAt, + TZ, + ); + const slots = expectedSlotsBetween( + DAILY, + new Date("2026-03-22T00:00:00.000Z"), + NOW, + ctx, + EVENTS, + ); + expect(slots).toHaveLength(7); + }); +}); diff --git a/src/lib/analytics/compliance/adapters.ts b/src/lib/analytics/compliance/adapters.ts index 3de29fb32..ba184dc73 100644 --- a/src/lib/analytics/compliance/adapters.ts +++ b/src/lib/analytics/compliance/adapters.ts @@ -9,10 +9,11 @@ import { type Occurrence, type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; -import type { - ComplianceIntakeInstant, - ComplianceMedicationContext, - ComplianceSchedule, +import { + complianceMintWindow, + type ComplianceIntakeInstant, + type ComplianceMedicationContext, + type ComplianceSchedule, } from "./types"; /** @@ -48,12 +49,22 @@ export function rollingIntakeInstants( export function toRecurrenceCtx( ctx: ComplianceMedicationContext, idTag: string, + /** + * v1.40 (#1024) — `"courses"` expands over the span of every course (the + * retrospective minters, which then drop the slots between courses); + * the default is the medication's own window (the forward-looking cycle). + */ + span: "current" | "courses" = "current", ): RecurrenceContext { + const window = + span === "courses" + ? complianceMintWindow(ctx) + : { startsOn: ctx.startsOn, endsOn: ctx.endsOn }; return { medication: { id: idTag, - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, + startsOn: window.startsOn, + endsOn: window.endsOn, oneShot: ctx.oneShot, createdAt: ctx.createdAt, }, diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts index 1ed582699..3626eca5d 100644 --- a/src/lib/analytics/compliance/cycle.ts +++ b/src/lib/analytics/compliance/cycle.ts @@ -1,6 +1,8 @@ // Extracted from the former single-file `compliance.ts`. See `../compliance.ts` // (the barrel) for the module map. Pure move — no logic changes. +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; import { nextOccurrenceAfter, type CanonicalSchedule, @@ -156,8 +158,14 @@ export function buildCurrentCycle( if (canonical.scheduleType === "PRN") continue; const n = canonical.rollingIntervalDays; if (n !== null && n > 0) { + // v1.40 (#1024) — an intake from a previous course does not anchor the + // current one; its first dose is due on its start day. + const intakeInCourse = + lastIntakeAt !== null && + (ctx.startsOn === null || + userDayKey(lastIntakeAt, ctx.timeZone) >= dayKeyOfDate(ctx.startsOn)); const anchor = - lastIntakeAt !== null + intakeInCourse && lastIntakeAt !== null ? new Date(lastIntakeAt.getTime() + n * DAY_MS) : (ctx.startsOn ?? ctx.createdAt); if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue; diff --git a/src/lib/analytics/compliance/ledger.ts b/src/lib/analytics/compliance/ledger.ts index 0d40c6336..02ea6610a 100644 --- a/src/lib/analytics/compliance/ledger.ts +++ b/src/lib/analytics/compliance/ledger.ts @@ -13,10 +13,12 @@ import { type HistoryIntake, } from "@/lib/medications/scheduling/dose-history"; import { userDayKey } from "@/lib/tz/resolver"; -import type { - ComplianceMedicationContext, - ComplianceSchedule, - IntakeEvent, +import { + complianceMintWindow, + slotInsideCourses, + type ComplianceMedicationContext, + type ComplianceSchedule, + type IntakeEvent, } from "./types"; import { intakeInstantsAtOrBefore, @@ -113,14 +115,17 @@ export function buildComplianceLedgerRows( now: Date, windowConfig?: DoseWindowConfig, ): DoseHistoryRow[] { + // v1.40 (#1024) — expand over the span of every course; the slots between + // courses are dropped below. + const mintWindow = complianceMintWindow(ctx); const medication: BandMinterMedication = { id: "compliance-tally", - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, + startsOn: mintWindow.startsOn, + endsOn: mintWindow.endsOn, oneShot: ctx.oneShot, createdAt: ctx.createdAt, }; - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally"); + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally", "courses"); const canonicalSchedules = schedules.map((s, i) => { const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`); // A legacy daily schedule carries only `windowStart` (no `timesOfDay`, @@ -188,7 +193,19 @@ export function buildComplianceLedgerRows( // Slots before the medication's creation count only when a recorded dose // claims them, so a caller's window reaching past the creation never // mints phantom misses (#1028). - const rows = reconstructDoseHistory(bands, intakes, now, ctx.createdAt); + const reconstructed = reconstructDoseHistory( + bands, + intakes, + now, + ctx.createdAt, + ); + + // v1.40 (#1024) — an expected slot on a day between two courses was never + // expected: drop it. Only slot rows go; a dose logged in the gap stays as + // the recorded intake it is. + const rows = reconstructed.filter( + (row) => row.kind !== "slot" || slotInsideCourses(ctx, row.at), + ); // v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a // pause interval. While a medication is paused no dose is expected, so a diff --git a/src/lib/analytics/compliance/occurrences.ts b/src/lib/analytics/compliance/occurrences.ts index 4b572e5c1..0da497ea5 100644 --- a/src/lib/analytics/compliance/occurrences.ts +++ b/src/lib/analytics/compliance/occurrences.ts @@ -3,10 +3,11 @@ import { occurrencesAcrossEras } from "@/lib/medications/scheduling/schedule-eras"; import type { Occurrence } from "@/lib/medications/scheduling/recurrence"; -import type { - ComplianceIntakeInstant, - ComplianceMedicationContext, - ComplianceSchedule, +import { + slotInsideCourses, + type ComplianceIntakeInstant, + type ComplianceMedicationContext, + type ComplianceSchedule, } from "./types"; import { expandComplianceOccurrences, @@ -36,7 +37,7 @@ export function expectedSlotCountForDay( ctx: ComplianceMedicationContext, intakes?: ComplianceIntakeInstant[], ): number { - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily"); + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily", "courses"); const now = new Date(); const retro = intakes && schedules.some((s) => s.rollingIntervalDays != null) @@ -63,7 +64,8 @@ export function expectedSlotCountForDay( retro, ), { oneShot: ctx.oneShot }, - ).length; + // v1.40 (#1024) — a day between two courses expects nothing. + ).filter((o) => slotInsideCourses(ctx, o.at)).length; } /** @@ -95,7 +97,7 @@ export function expectedSlotsBetween( ctx: ComplianceMedicationContext, intakes?: ComplianceIntakeInstant[], ): Occurrence[] { - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots"); + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots", "courses"); const effectiveFrom = ctx.createdAt.getTime() > from.getTime() ? ctx.createdAt : from; // v1.13.x — for a ROLLING schedule the expected grid is reconstructed from @@ -123,5 +125,6 @@ export function expectedSlotsBetween( retro, ), { oneShot: ctx.oneShot }, - ); + // v1.40 (#1024) — a day between two courses expects nothing. + ).filter((o) => slotInsideCourses(ctx, o.at)); } diff --git a/src/lib/analytics/compliance/types.ts b/src/lib/analytics/compliance/types.ts index 4e0f7ac61..198d52605 100644 --- a/src/lib/analytics/compliance/types.ts +++ b/src/lib/analytics/compliance/types.ts @@ -3,6 +3,11 @@ import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; import type { ScheduleType } from "@/lib/medications/scheduling/recurrence"; +import { + isInsideCourses, + type CourseSpan, +} from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; export interface IntakeEvent { takenAt: Date | null; @@ -133,6 +138,16 @@ export const SCHEDULE_COMPLIANCE_SELECT = { doseWindows: true, } as const; +/** + * v1.40 (#1024) — the ONE relation select for the courses a compliance + * computation threads into {@link buildComplianceMedicationContext}, the + * sibling of {@link SCHEDULE_COMPLIANCE_SELECT}. + */ +export const COURSES_COMPLIANCE_SELECT = { + select: { startsOn: true, endsOn: true }, + orderBy: { startsOn: "asc" }, +} as const; + /** * v1.7.0 SB-SCHED-2 — medication-level context the canonical engine * needs to expand expected slots. When supplied, `calculateCompliance` @@ -162,6 +177,50 @@ export interface ComplianceMedicationContext { * callers without eras keep counting every expected slot. */ pauseEras?: MedicationPauseEraLike[]; + /** + * v1.40 (#1024) — the medication's courses. `startsOn`/`endsOn` above are + * the row's projection (the latest course). With two or more courses the + * retrospective minters expand over the span from the first course's start + * to the last one's end and drop every expected slot whose local day falls + * in no course, so a gap between courses expects nothing. Absent or a + * single course: today's behaviour, the projection window alone. + */ + courses?: CourseSpan[]; +} + +/** + * v1.40 (#1024) — the window the retrospective minters expand over: the span + * of every course when there are several, else the medication's own window. + */ +export function complianceMintWindow(ctx: ComplianceMedicationContext): { + startsOn: Date | null; + endsOn: Date | null; +} { + const courses = ctx.courses; + if (!courses || courses.length < 2) { + return { startsOn: ctx.startsOn, endsOn: ctx.endsOn }; + } + const sorted = [...courses].sort( + (a, b) => a.startsOn.getTime() - b.startsOn.getTime(), + ); + return { + startsOn: sorted[0].startsOn, + endsOn: sorted[sorted.length - 1].endsOn, + }; +} + +/** + * v1.40 (#1024) — whether an expected slot at `at` belongs to a course. True + * for every slot when the medication has fewer than two courses (the + * projection window already bounds the expansion). + */ +export function slotInsideCourses( + ctx: ComplianceMedicationContext, + at: Date, +): boolean { + const courses = ctx.courses; + if (!courses || courses.length < 2) return true; + return isInsideCourses(userDayKey(at, ctx.timeZone), courses); } /** @@ -190,6 +249,8 @@ export function buildComplianceMedicationContext( scheduleRevisions?: ScheduleRevisionLike[]; /** v1.25 H-MED1 — thread the pause eras when the caller carries them. */ pauseEras?: MedicationPauseEraLike[]; + /** v1.40 (#1024) — thread the courses when the caller carries them. */ + courses?: CourseSpan[]; }, lastIntakeAt: Date | null, timeZone: string, @@ -203,6 +264,7 @@ export function buildComplianceMedicationContext( timeZone, ...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }), ...(med.pauseEras && { pauseEras: med.pauseEras }), + ...(med.courses && { courses: med.courses }), }; } diff --git a/src/lib/analytics/schedule-anchored-compliance.ts b/src/lib/analytics/schedule-anchored-compliance.ts index f9d9b1d9f..0889e9c08 100644 --- a/src/lib/analytics/schedule-anchored-compliance.ts +++ b/src/lib/analytics/schedule-anchored-compliance.ts @@ -31,6 +31,7 @@ import { prisma } from "@/lib/db"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, expectedSlotCountForDay, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, @@ -70,6 +71,8 @@ export async function buildScheduleAnchoredComplianceBuckets( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 6206b7254..9e39fb077 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -335,6 +335,7 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // note — the last plaintext PHI columns left after the v1.23 rollout. { model: "MedicationSideEffect", field: "notesEncrypted", kind: "bytes" }, { model: "MedicationDoseChange", field: "noteEncrypted", kind: "bytes" }, + { model: "MedicationCourse", field: "noteEncrypted", kind: "bytes" }, { model: "MedicationInventoryItem", field: "notesEncrypted", kind: "bytes" }, // ───── v1.39.3 custom-metric reading note (Bytes column) ───── diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index ad8a4f43e..d4b40a9de 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -76,6 +76,8 @@ export const WIPE_MODELS = [ // The account's own category names (v1.40, #1041). No child rows; the // medications that name one go with their own entry. "MedicationCategoryLabel", + // The courses (v1.40, #1024), before the medications they cascade from. + "MedicationCourse", // ── Mood ──────────────────────────────────────────────────────────────── // The day context, before the entry it hangs off: it cascades either way, diff --git a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts index 61b436cff..b3e53dc5f 100644 --- a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts +++ b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts @@ -1,4 +1,5 @@ import type { jsPDF } from "jspdf"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; import autoTable from "jspdf-autotable"; import { adherenceRatePercent } from "../doctor-report-data"; import { @@ -67,7 +68,20 @@ export function buildMedicationMoodWellnessSection( doc.text(t("doctorReport.complianceTitle"), margin, y); y += 6; - const compRows = complianceEntries.map(([, c]) => { + // v1.40 (#1024) — a medication taken in courses names each course inside + // the window under its name, one line per course. + const courseLines = (id: string): string[] => + (data.medications.find((m) => m.id === id)?.courses ?? []).map((c) => + c.endsOn + ? t("doctorReport.courseRange", { + start: fmtDate(dateOnlyAtNoonUtc(c.startsOn).toISOString()), + end: fmtDate(dateOnlyAtNoonUtc(c.endsOn).toISOString()), + }) + : t("doctorReport.courseSince", { + start: fmtDate(dateOnlyAtNoonUtc(c.startsOn).toISOString()), + }), + ); + const compRows = complianceEntries.map(([id, c]) => { // v1.17 W1a — `total` is the ledger rate denominator (taken + missed, // deliberate skips excluded), so `taken / total` matches the app's // detail-page adherence %. The column is labelled "Expected" rather @@ -77,7 +91,7 @@ export function buildMedicationMoodWellnessSection( const ratePct = adherenceRatePercent(c.taken, c.total); const rate = ratePct !== null ? `${num(ratePct, 0)}%` : "—"; return [ - c.name, + [c.name, ...courseLines(id)].join("\n"), String(c.taken), String(c.skipped), String(c.missed), diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index cdacd85b1..cf45a0c6b 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -137,6 +137,17 @@ export interface DoctorReportData { windowEnd: string; label: string | null; }>; + /** + * v1.40 (#1024) — the courses that overlap the report window, ascending + * (`YYYY-MM-DD`, both ends inclusive; `endsOn` null = open), with where + * the window's last day sits in each. Empty for a medication taken + * continuously. Optional so older fixtures typecheck. + */ + courses?: Array<{ + startsOn: string; + endsOn: string | null; + status: "UPCOMING" | "CURRENT" | "ENDED"; + }>; }>; /** * v1.9.0 — acted medication-intake events over the report window, fed @@ -537,6 +548,8 @@ export interface DoctorReportComplianceMedication { scheduleRevisions?: ScheduleRevisionLike[]; /** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */ pauseEras?: MedicationPauseEraLike[]; + /** v1.40 (#1024) — the courses, so a gap between two expects nothing. */ + courses?: { startsOn: Date; endsOn: Date | null }[]; } /** A window-bounded intake row keyed to its medication. */ diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 3d94b9e17..61d5abe4f 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -162,6 +162,7 @@ function seed() { ], scheduleRevisions: [], pauseEras: [], + courses: [], doseChanges: [ { effectiveFrom: new Date("2026-01-05T00:00:00.000Z"), diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index a92c0b886..79aaacad5 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -15,6 +15,7 @@ * * Pure data assembly — no auth, no rate limit, no audit. Idempotent. */ +import { courseStatusOn, dayKeyOfDate } from "@/lib/medications/course-window"; import { prisma } from "@/lib/db"; import { getEffectiveRange, @@ -34,7 +35,10 @@ import { } from "@/lib/analytics/sleep-night"; import { buildCycleExportSummary } from "@/lib/cycle/export-data"; import { resolveModuleMap } from "@/lib/modules/gate"; -import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; +import { + COURSES_COMPLIANCE_SELECT, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; import { MEASUREMENT_LEAF_IDS } from "@/lib/report-selection/catalogue"; import type { ReportSelection } from "@/lib/report-selection/selection"; import { @@ -256,25 +260,54 @@ export async function collectDoctorReportData( // whenever it was entered. Dose history and the last intake stop // at the window's end too, so a report for March does not show a // dose set in June. + // + // v1.40 (#1024) — a medication taken in courses is part of the + // window when one of its courses overlaps it; the row's own + // window is only the latest course. One without courses keeps + // the rule above. where: { userId, active: true, - AND: [ + OR: [ { - OR: [ - { startsOn: { lte: lastDay } }, - { startsOn: null, createdAt: { lte: end } }, - { - intakeEvents: { - some: { - deletedAt: null, - scheduledFor: { gte: start, lte: end }, - }, - }, + courses: { + some: { + startsOn: { lte: lastDay }, + OR: [{ endsOn: null }, { endsOn: { gte: firstDay } }], }, + }, + }, + { + // A dose logged inside the window outside every course (the + // person knows better than the window) still places it. + courses: { some: {} }, + intakeEvents: { + some: { + deletedAt: null, + scheduledFor: { gte: start, lte: end }, + }, + }, + }, + { + courses: { none: {} }, + AND: [ + { + OR: [ + { startsOn: { lte: lastDay } }, + { startsOn: null, createdAt: { lte: end } }, + { + intakeEvents: { + some: { + deletedAt: null, + scheduledFor: { gte: start, lte: end }, + }, + }, + }, + ], + }, + { OR: [{ endsOn: null }, { endsOn: { gte: firstDay } }] }, ], }, - { OR: [{ endsOn: null }, { endsOn: { gte: firstDay } }] }, ], }, include: { @@ -292,6 +325,8 @@ export async function collectDoctorReportData( }, }, pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, doseChanges: { where: { effectiveFrom: { lte: end } }, orderBy: { effectiveFrom: "asc" }, @@ -438,6 +473,7 @@ export async function collectDoctorReportData( schedules: m.schedules, scheduleRevisions: m.scheduleRevisions, pauseEras: m.pauseEras, + courses: m.courses, })), intakeEvents.map((e) => ({ medicationId: e.medicationId, @@ -682,6 +718,18 @@ export async function collectDoctorReportData( windowEnd: s.windowEnd, label: s.label, })), + // v1.40 (#1024) — the courses inside the window, one line each. + courses: m.courses + .filter( + (c) => + c.startsOn.getTime() <= lastDay.getTime() && + (c.endsOn === null || c.endsOn.getTime() >= firstDay.getTime()), + ) + .map((c) => ({ + startsOn: dayKeyOfDate(c.startsOn), + endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + status: courseStatusOn(c, dayKeyOfDate(lastDay)), + })), })) : [], medicationAdministrations: gate.admits("MEDICATION_ADMINISTRATIONS") diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 351b9ff23..5cbfcf0fa 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -190,6 +190,23 @@ function makePrisma() { createdAt: new Date("2026-07-15T08:01:00.000Z"), }, ], + // v1.40 (#1024) — one ended course with a note, one running. + courses: [ + { + id: "course-march", + startsOn: new Date("2026-03-01T00:00:00.000Z"), + endsOn: new Date("2026-03-07T00:00:00.000Z"), + noteEncrypted: encryptToBytes("the March flu"), + createdAt: new Date("2026-03-01T08:00:00.000Z"), + }, + { + id: "course-june", + startsOn: new Date("2026-06-10T00:00:00.000Z"), + endsOn: null, + noteEncrypted: null, + createdAt: new Date("2026-06-10T08:00:00.000Z"), + }, + ], // A two-step ramp, one row carrying an encrypted note and one // without, so both arms of the note contract are exercised. doseChanges: [ @@ -743,6 +760,35 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { * into a portable export and losing the note are opposite failures of the same * decision. */ +describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => { + it.each(["portable-export", "disaster-recovery"] as const)( + "carries every course with its note readable in a %s file", + async (purpose) => { + installSectionMocks(); + const { payload } = await buildFullBackupPayload( + makePrisma() as never, + "user-1", + { purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") }, + ); + const parsed = parseBackupPayload(payload); + expect(parsed.medications[0].courses).toEqual([ + { + startsOn: "2026-03-01", + endsOn: "2026-03-07", + note: "the March flu", + createdAt: "2026-03-01T08:00:00.000Z", + }, + { + startsOn: "2026-06-10", + endsOn: null, + note: null, + createdAt: "2026-06-10T08:00:00.000Z", + }, + ]); + }, + ); +}); + describe("buildFullBackupPayload — medication side effects", () => { it("decrypts the note and emits no ciphertext in a portable export", async () => { installSectionMocks(); diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 14ebbb35f..acbe9e7dc 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -86,6 +86,9 @@ export const BACKED_UP_MODELS = [ // The categories the person named themselves (v1.40, #1041). A medication // filed under one carries its key; without the row the key reads as Other. "MedicationCategoryLabel", + // The courses (v1.40, #1024). Once a second one exists the earlier spans + // live here and nowhere else; the medication row keeps only the latest. + "MedicationCourse", // ── Mood ────────────────────────────────────────────────────────────────── "MoodEntry", @@ -314,6 +317,7 @@ export const TWO_ENDED_MODELS = [ "MedicationDoseChange", "MedicationCategoryAssignment", "MedicationCategoryLabel", + "MedicationCourse", "MoodEntry", "MoodContext", "MoodEntryTagLink", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 63892829f..236e25188 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -20,6 +20,8 @@ * one reaches the other; the integration suite pins the two outputs byte for * byte rather than trusting that sentence. */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; import { Buffer } from "node:buffer"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; @@ -735,6 +737,7 @@ export async function buildFullBackupPayload( schedules: true, sideEffects: { orderBy: { occurredAt: "desc" } }, pauseEras: { orderBy: { pausedAt: "asc" } }, + courses: { orderBy: { startsOn: "asc" } }, doseChanges: { orderBy: { effectiveFrom: "asc" } }, inventoryItems: { orderBy: { createdAt: "asc" } }, inventoryEvents: { orderBy: { occurredAt: "asc" } }, @@ -1119,6 +1122,15 @@ export async function buildFullBackupPayload( resumedAt: p.resumedAt ? p.resumedAt.toISOString() : null, createdAt: p.createdAt.toISOString(), })), + // v1.40 (#1024) — the courses: "when did I last take this" lives here + // and nowhere else once a second course exists. The medication's own + // window above is the projection of the latest one. + courses: m.courses.map((c) => ({ + startsOn: dayKeyOfDate(c.startsOn), + endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null, + createdAt: c.createdAt.toISOString(), + })), // Titration: when the dose moved, to what, and why. The drug comes // back at today's dose either way; without these the ramp that led to // it is gone, and that ramp is what a prescriber asks about. diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index f7299abac..292e01ca7 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -27,6 +27,11 @@ * replacement is still one transaction, so a failure part-way leaves the * account exactly as it was. */ +import { + dateOfDayKey, + dayKeyOfDate, + projectCourseWindow, +} from "@/lib/medications/course-window"; import { Buffer } from "node:buffer"; import { prisma, toJson } from "@/lib/db"; @@ -1301,6 +1306,59 @@ export async function restoreBackup( ); } + // v1.40 (#1024) — the courses, then the window they project. A file + // written before courses existed carries none; the medication's own + // window then describes its one course, derived exactly as the + // migration's backfill did (an end-only window runs from creation, + // or from its end day when that lies earlier). + const courseRows: Array<{ + startsOn: Date; + endsOn: Date | null; + note: string | null; + createdAt?: Date; + }> = + m.courses !== undefined + ? m.courses.map((c) => ({ + startsOn: dateOfDayKey(c.startsOn), + endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null, + note: c.note ?? null, + ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + })) + : created.startsOn || created.endsOn + ? [ + { + startsOn: + created.startsOn ?? + (created.endsOn && + created.endsOn.getTime() < + dateOfDayKey( + dayKeyOfDate(created.createdAt), + ).getTime() + ? created.endsOn + : dateOfDayKey(dayKeyOfDate(created.createdAt))), + endsOn: created.endsOn, + note: null, + }, + ] + : []; + if (courseRows.length > 0) { + await tx.medicationCourse.createMany({ + data: courseRows.map((c) => ({ + medicationId: created.id, + userId: ownerId, + startsOn: c.startsOn, + endsOn: c.endsOn, + noteEncrypted: c.note ? encryptToBytes(c.note) : null, + ...(c.createdAt ? { createdAt: c.createdAt } : {}), + })), + }); + const window = projectCourseWindow(courseRows); + await tx.medication.update({ + where: { id: created.id }, + data: { startsOn: window.startsOn, endsOn: window.endsOn }, + }); + } + // ── The archived schedule eras, in two passes ──────────────── // // An era carries `supersededByRevisionId`, the pointer from a diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index e8844d820..473758eae 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -458,6 +458,38 @@ describe("buildFhirDocumentBundle", () => { expect(meds[0].dosage?.[0].text).toBe("5mg"); }); + it("emits one statement per course with its period and status (v1.40, #1024)", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [ + { + id: "med-tamiflu", + name: "Tamiflu", + dose: "75 mg", + schedules: [], + courses: [ + { startsOn: "2026-03-01", endsOn: "2026-03-07", status: "ENDED" }, + { startsOn: "2026-06-10", endsOn: null, status: "CURRENT" }, + ], + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const meds = bundle.entry + .map((e) => e.resource) + .filter( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + expect(meds.map((m) => [m.status, m.effectivePeriod])).toEqual([ + ["completed", { start: "2026-03-01", end: "2026-03-07" }], + ["active", { start: "2026-06-10" }], + ]); + expect(new Set(meds.map((m) => m.id)).size).toBe(2); + }); + it("keeps a text-only medicationCodeableConcept when no codes are stored", () => { const bundle = buildFhirDocumentBundle( makeData(), diff --git a/src/lib/fhir/resources/medications.ts b/src/lib/fhir/resources/medications.ts index ea14d2be7..56cbf7f46 100644 --- a/src/lib/fhir/resources/medications.ts +++ b/src/lib/fhir/resources/medications.ts @@ -154,22 +154,50 @@ export function medicationStatementsFromReportData( const germanAtc = options.germanAtc ?? false; const statements: FhirMedicationStatement[] = []; let medSeq = 0; + // v1.40 (#1024) — a medication taken in courses is one statement per + // course inside the window, each with its `effectivePeriod` and a status + // read against the window's last day: `completed` once it has ended, + // `intended` before it starts, `active` otherwise. A medication taken + // continuously stays one `active` statement, as before. for (const med of data.medications) { - medSeq += 1; - const stmt: FhirMedicationStatement = { - resourceType: "MedicationStatement", - id: `med-${medSeq}`, - status: "active", - medicationCodeableConcept: medicationConcept( - med.name, - med.atcCode, - med.rxNormCode, - germanAtc, - ), - subject: patientRef, - }; - if (med.dose) stmt.dosage = [{ text: med.dose }]; - statements.push(stmt); + const concept = medicationConcept( + med.name, + med.atcCode, + med.rxNormCode, + germanAtc, + ); + const courses = med.courses ?? []; + const spans: Array<{ + status: FhirMedicationStatement["status"]; + effectivePeriod?: { start: string; end?: string }; + }> = + courses.length === 0 + ? [{ status: "active" }] + : courses.map((c) => ({ + status: + c.status === "ENDED" + ? "completed" + : c.status === "UPCOMING" + ? "intended" + : "active", + effectivePeriod: { + start: c.startsOn, + ...(c.endsOn ? { end: c.endsOn } : {}), + }, + })); + for (const span of spans) { + medSeq += 1; + const stmt: FhirMedicationStatement = { + resourceType: "MedicationStatement", + id: `med-${medSeq}`, + status: span.status, + medicationCodeableConcept: concept, + subject: patientRef, + ...(span.effectivePeriod && { effectivePeriod: span.effectivePeriod }), + }; + if (med.dose) stmt.dosage = [{ text: med.dose }]; + statements.push(stmt); + } } return statements; } diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 649d08a32..9685b4419 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -160,7 +160,7 @@ export interface FhirPeriod { export interface FhirMedicationStatement { resourceType: "MedicationStatement"; id: string; - status: "active" | "completed" | "stopped" | "unknown"; + status: "active" | "completed" | "intended" | "stopped" | "unknown"; medicationCodeableConcept: FhirCodeableConcept; subject: FhirReference; effectivePeriod?: FhirPeriod; diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index b5bf3c6ec..4057590a4 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -23,6 +23,7 @@ import { } from "@/lib/analytics/correlations"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, calculateCompliance, expectsDoses, lastNonSkippedTakenAt, @@ -338,6 +339,8 @@ export async function prepareBloodPressureStatusForUser( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index aaed68cfa..7bbd1d5fb 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -22,6 +22,7 @@ import { prisma } from "@/lib/db"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, buildMedicationComplianceBundle, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, @@ -83,6 +84,8 @@ export async function fetchComplianceSeries( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, orderBy: { name: "asc" }, }); diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 58c1e0742..93b287ffb 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -8,6 +8,7 @@ import { summarize } from "@/lib/analytics/trends"; import type { DataPoint } from "@/lib/analytics/trends"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, calculateCompliance, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, @@ -1665,6 +1666,8 @@ export async function extractFeatures( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }); diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 9008c0435..299b97a64 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -7,6 +7,7 @@ import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; import type { Locale } from "@/lib/i18n/config"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, buildMedicationComplianceBundle, dailyComplianceRatesFromLedger, expectsDoses, @@ -207,6 +208,8 @@ export async function prepareMedicationComplianceStatusForUser( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, orderBy: { name: "asc" }, }); diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index 357894bab..f0724414e 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -28,6 +28,7 @@ import { prisma } from "@/lib/db"; import { fenceUserText } from "@/lib/ai/coach/data-fence"; import { resolveMedicationCategories } from "@/lib/medication-category"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; import { annotate } from "@/lib/logging/context"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; @@ -192,7 +193,13 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ } const medications = await prisma.medication.findMany({ where: { userId: ctx.userId }, - include: { schedules: true }, + include: { + schedules: true, + courses: { + select: { startsOn: true, endsOn: true }, + orderBy: { startsOn: "asc" }, + }, + }, orderBy: { createdAt: "desc" }, }); annotate({ @@ -222,6 +229,12 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ paused: med.pausedAt !== null, startsOn: med.startsOn ? med.startsOn.toISOString() : null, endsOn: med.endsOn ? med.endsOn.toISOString() : null, + // v1.40 (#1024) — every course, so "when did they last take it" is + // answerable; startsOn/endsOn above stay the current course. + courses: (med.courses ?? []).map((c) => ({ + startsOn: dayKeyOfDate(c.startsOn), + endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + })), schedules: med.schedules.map((s) => ({ label: s.label ?? null, dose: s.dose ?? null, diff --git a/src/lib/medications/__tests__/course-window.test.ts b/src/lib/medications/__tests__/course-window.test.ts new file mode 100644 index 000000000..292ffd117 --- /dev/null +++ b/src/lib/medications/__tests__/course-window.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { + canStartCourse, + courseStatusOn, + dateOfDayKey, + isInsideCourses, + previousCourseEndedOn, + projectCourseWindow, + validateCourses, + type CourseSpan, +} from "@/lib/medications/course-window"; + +const d = dateOfDayKey; +const course = (start: string, end: string | null): CourseSpan => ({ + startsOn: d(start), + endsOn: end ? d(end) : null, +}); + +const MARCH = course("2026-03-01", "2026-03-07"); +const JUNE = course("2026-06-10", "2026-06-16"); + +describe("projectCourseWindow", () => { + it("is the latest course, whatever the input order", () => { + expect(projectCourseWindow([JUNE, MARCH])).toEqual({ + startsOn: d("2026-06-10"), + endsOn: d("2026-06-16"), + }); + }); + + it("is empty with no course (chronic since creation)", () => { + expect(projectCourseWindow([])).toEqual({ startsOn: null, endsOn: null }); + }); + + it.each([ + ["before the first course", "2026-02-20"], + ["inside the first course", "2026-03-03"], + ["between the courses", "2026-04-01"], + ["inside the latest course", "2026-06-12"], + ["after the latest course", "2026-08-01"], + ])("matches the course a reader of today would pick, %s", (_label, today) => { + // The written rule: the course containing today, else the next upcoming, + // else the most recent ended. Under the invariant that is the latest. + const courses = [MARCH, JUNE]; + if (validateCourses(courses, today, false) !== null) return; + const containing = courses.find( + (c) => courseStatusOn(c, today) === "CURRENT", + ); + const upcoming = courses.find( + (c) => courseStatusOn(c, today) === "UPCOMING", + ); + const picked = containing ?? upcoming ?? courses[courses.length - 1]; + expect(projectCourseWindow(courses).startsOn).toEqual(picked.startsOn); + }); +}); + +describe("validateCourses", () => { + it("accepts adjacent courses and refuses a shared day", () => { + expect( + validateCourses( + [MARCH, course("2026-03-08", "2026-03-10")], + "2026-04-01", + false, + ), + ).toBeNull(); + expect( + validateCourses( + [MARCH, course("2026-03-07", "2026-03-10")], + "2026-04-01", + false, + ), + ).toBe("overlap"); + }); + + it("refuses an open course that is not the latest", () => { + expect( + validateCourses([course("2026-01-01", null), JUNE], "2026-07-01", false), + ).toBe("overlap"); + }); + + it("refuses a second course queued behind one that has not ended", () => { + // Today is inside March, and June is queued after it. + expect(validateCourses([MARCH, JUNE], "2026-03-03", false)).toBe( + "currentOpen", + ); + expect(validateCourses([MARCH, JUNE], "2026-03-08", false)).toBeNull(); + }); + + it("refuses an end before the start", () => { + expect( + validateCourses( + [course("2026-03-07", "2026-03-01")], + "2026-04-01", + false, + ), + ).toBe("invalidRange"); + }); + + it("refuses a second course on a one-time medication", () => { + expect(validateCourses([MARCH], "2026-04-01", true)).toBeNull(); + expect(validateCourses([MARCH, JUNE], "2026-07-01", true)).toBe("oneShot"); + }); +}); + +describe("courseStatusOn", () => { + it("is inclusive at both ends", () => { + expect(courseStatusOn(MARCH, "2026-02-28")).toBe("UPCOMING"); + expect(courseStatusOn(MARCH, "2026-03-01")).toBe("CURRENT"); + expect(courseStatusOn(MARCH, "2026-03-07")).toBe("CURRENT"); + expect(courseStatusOn(MARCH, "2026-03-08")).toBe("ENDED"); + expect(courseStatusOn(course("2026-03-01", null), "2027-01-01")).toBe( + "CURRENT", + ); + }); +}); + +describe("isInsideCourses", () => { + it("admits the course days and not the gap", () => { + const courses = [MARCH, JUNE]; + expect(isInsideCourses("2026-03-01", courses)).toBe(true); + expect(isInsideCourses("2026-03-07", courses)).toBe(true); + expect(isInsideCourses("2026-03-08", courses)).toBe(false); + expect(isInsideCourses("2026-06-16", courses)).toBe(true); + expect(isInsideCourses("2026-06-17", courses)).toBe(false); + }); + + it("places no restriction without courses", () => { + expect(isInsideCourses("2026-03-08", [])).toBe(true); + }); +}); + +describe("previousCourseEndedOn and canStartCourse", () => { + it("names the end of the latest ended course", () => { + expect(previousCourseEndedOn([MARCH, JUNE], "2026-06-12")).toEqual( + d("2026-03-07"), + ); + expect(previousCourseEndedOn([MARCH, JUNE], "2026-07-01")).toEqual( + d("2026-06-16"), + ); + expect(previousCourseEndedOn([MARCH], "2026-03-03")).toBeNull(); + }); + + it("offers a new course only when every course has ended", () => { + const base = { active: true, oneShot: false, todayKey: "2026-07-01" }; + expect(canStartCourse({ ...base, courses: [MARCH, JUNE] })).toBe(true); + expect( + canStartCourse({ + ...base, + courses: [MARCH, JUNE], + todayKey: "2026-06-12", + }), + ).toBe(false); + // Chronic (no course) is running; a one-time or paused medication never. + expect(canStartCourse({ ...base, courses: [] })).toBe(false); + expect(canStartCourse({ ...base, oneShot: true, courses: [MARCH] })).toBe( + false, + ); + expect(canStartCourse({ ...base, active: false, courses: [MARCH] })).toBe( + false, + ); + }); +}); diff --git a/src/lib/medications/course-window.ts b/src/lib/medications/course-window.ts new file mode 100644 index 000000000..8b6ca8b5e --- /dev/null +++ b/src/lib/medications/course-window.ts @@ -0,0 +1,152 @@ +/** + * v1.40 (#1024) — several courses per medication: the pure rules. + * + * A course is a calendar span `[startsOn, endsOn]` (both inclusive, calendar + * days on the person's clock, `endsOn` null = open). A medication with no + * course rows is "chronic since creation", as before this release; one with + * course rows keeps `Medication.startsOn/endsOn` as a projection of ONE of + * them, which every engine that predates courses (reminders, the next-due + * line, `intakeActionable`) keeps reading unchanged. + * + * The invariant that keeps the projection time-independent: courses never + * overlap, and every course except the latest-starting one has ended before + * today. Under it the course containing today, else the next upcoming one, + * else the most recently ended one is always simply the latest course, so a + * projection written at the last course write stays correct as days pass. A + * write that would break the invariant is refused (`validateCourses`). + * + * Pure: no database, no clock (callers pass `today`). + */ +import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; + +export interface CourseSpan { + startsOn: Date; + endsOn: Date | null; +} + +export type CourseStatus = "UPCOMING" | "CURRENT" | "ENDED"; + +/** `YYYY-MM-DD` of a `@db.Date` value. */ +export const dayKeyOfDate = dateOnlyKey; + +/** A `YYYY-MM-DD` key as the `@db.Date` value Prisma writes. */ +export const dateOfDayKey = dayKeyAsUtcMidnight; + +/** Courses ascending by start. */ +export function sortCourses(courses: readonly T[]): T[] { + return [...courses].sort( + (a, b) => a.startsOn.getTime() - b.startsOn.getTime(), + ); +} + +/** + * The window `Medication.startsOn/endsOn` carries: the latest course, or + * nothing at all when the medication has no course (chronic since creation). + */ +export function projectCourseWindow(courses: readonly CourseSpan[]): { + startsOn: Date | null; + endsOn: Date | null; +} { + if (courses.length === 0) return { startsOn: null, endsOn: null }; + const latest = sortCourses(courses)[courses.length - 1]; + return { startsOn: latest.startsOn, endsOn: latest.endsOn }; +} + +/** Where `todayKey` sits in one course. Both ends inclusive. */ +export function courseStatusOn( + course: CourseSpan, + todayKey: string, +): CourseStatus { + if (todayKey < dayKeyOfDate(course.startsOn)) return "UPCOMING"; + if (course.endsOn && todayKey > dayKeyOfDate(course.endsOn)) return "ENDED"; + return "CURRENT"; +} + +export type CourseRefusal = + /** `endsOn` before `startsOn`. */ + | "invalidRange" + /** Two courses share at least one day. */ + | "overlap" + /** A course other than the latest has not ended yet. */ + | "currentOpen" + /** A one-time medication has more than one course. */ + | "oneShot"; + +/** + * Whether a course set may be stored. Adjacent courses (one ends the day + * before the next starts) are fine; sharing a day is an overlap. + */ +export function validateCourses( + courses: readonly CourseSpan[], + todayKey: string, + oneShot: boolean, +): CourseRefusal | null { + for (const c of courses) { + if (c.endsOn && c.endsOn.getTime() < c.startsOn.getTime()) { + return "invalidRange"; + } + } + if (oneShot && courses.length > 1) return "oneShot"; + const sorted = sortCourses(courses); + for (let i = 1; i < sorted.length; i += 1) { + const previous = sorted[i - 1]; + if ( + previous.endsOn === null || + previous.endsOn.getTime() >= sorted[i].startsOn.getTime() + ) { + return "overlap"; + } + } + for (let i = 0; i < sorted.length - 1; i += 1) { + if (courseStatusOn(sorted[i], todayKey) !== "ENDED") return "currentOpen"; + } + return null; +} + +/** + * Whether an instant's local day (`dayKey`) falls inside any course. No + * courses means no restriction (chronic since creation). + */ +export function isInsideCourses( + dayKey: string, + courses: readonly CourseSpan[], +): boolean { + if (courses.length === 0) return true; + return courses.some( + (c) => + dayKey >= dayKeyOfDate(c.startsOn) && + (c.endsOn === null || dayKey <= dayKeyOfDate(c.endsOn)), + ); +} + +/** + * The end of the latest course that has ended before today, or null. "When + * did I last take this" for a medication that is not running now. + */ +export function previousCourseEndedOn( + courses: readonly CourseSpan[], + todayKey: string, +): Date | null { + const ended = sortCourses(courses).filter( + (c) => courseStatusOn(c, todayKey) === "ENDED", + ); + return ended.length === 0 ? null : ended[ended.length - 1].endsOn; +} + +/** + * Whether a new course may start: the medication is active and not a + * one-time one, it has at least one course, and every course has ended. A + * medication with no course is chronic and running, so it ends its course + * first ("End course" writes one). + */ +export function canStartCourse(args: { + active: boolean; + oneShot: boolean; + courses: readonly CourseSpan[]; + todayKey: string; +}): boolean { + if (!args.active || args.oneShot || args.courses.length === 0) return false; + return args.courses.every( + (c) => courseStatusOn(c, args.todayKey) === "ENDED", + ); +} diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts new file mode 100644 index 000000000..f7c82c468 --- /dev/null +++ b/src/lib/medications/courses.ts @@ -0,0 +1,503 @@ +/** + * v1.40 (#1024) — the one writer of a medication's courses and of the window + * the medication row projects from them. + * + * Every write runs in one transaction under a per-medication advisory lock: + * load the courses, apply the change, refuse a set that breaks the course + * rules (`validateCourses`), write, then rewrite `Medication.startsOn/endsOn` + * from the result (`projectCourseWindow`). Nothing else writes those two + * columns for a medication that has courses, so reminders, the next-due line + * and `intakeActionable` — which read the columns — can never disagree with + * the course list. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { prisma } from "@/lib/db"; +import { + canStartCourse, + courseStatusOn, + dateOfDayKey, + dayKeyOfDate, + previousCourseEndedOn, + projectCourseWindow, + sortCourses, + validateCourses, + type CourseRefusal, + type CourseStatus, +} from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; + +type Tx = Prisma.TransactionClient; + +export type CourseWriteRefusal = + | CourseRefusal + /** Clearing the window of a medication that has several courses. */ + | "windowRequired" + /** The course or the medication is not the caller's. */ + | "notFound"; + +export class CourseWriteError extends Error { + constructor(readonly refusal: CourseWriteRefusal) { + super(`Course write refused: ${refusal}`); + this.name = "CourseWriteError"; + } +} + +/** The wire `meta.errorCode` and status for each refusal. */ +export function courseRefusalResponse(refusal: CourseWriteRefusal): { + status: number; + errorCode: string; + message: string; +} { + switch (refusal) { + case "notFound": + return { + status: 404, + errorCode: "medications.course.notFound", + message: "Course not found", + }; + case "overlap": + return { + status: 422, + errorCode: "medications.course.overlap", + message: "Courses may not share a day", + }; + case "currentOpen": + return { + status: 422, + errorCode: "medications.course.currentOpen", + message: "End the running course before adding one after it", + }; + case "oneShot": + return { + status: 422, + errorCode: "medications.course.oneShot", + message: "A one-time medication has a single course", + }; + case "invalidRange": + return { + status: 422, + errorCode: "medications.course.invalidRange", + message: "A course cannot end before it starts", + }; + case "windowRequired": + return { + status: 422, + errorCode: "medications.course.windowRequired", + message: + "A medication with several courses keeps a start date; delete a course instead", + }; + } +} + +export interface CourseRow { + id: string; + startsOn: Date; + endsOn: Date | null; + noteEncrypted: Uint8Array | null; +} + +async function lockMedication(tx: Tx, medicationId: string) { + await tx.$queryRaw` + SELECT pg_advisory_xact_lock( + hashtext('medication-course'), + hashtext(${medicationId}) + )::text AS locked + `; +} + +async function loadOwned(tx: Tx, userId: string, medicationId: string) { + const medication = await tx.medication.findFirst({ + where: { id: medicationId, userId }, + select: { + id: true, + oneShot: true, + startsOn: true, + endsOn: true, + createdAt: true, + }, + }); + if (!medication) throw new CourseWriteError("notFound"); + const courses = await tx.medicationCourse.findMany({ + where: { medicationId }, + orderBy: { startsOn: "asc" }, + select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true }, + }); + return { medication, courses }; +} + +type ProjectedWindow = { startsOn: Date | null; endsOn: Date | null }; + +/** Write the medication's window from its courses. The only writer. */ +async function project(tx: Tx, medicationId: string): Promise { + const courses = await tx.medicationCourse.findMany({ + where: { medicationId }, + select: { startsOn: true, endsOn: true }, + }); + const window = projectCourseWindow(courses); + await tx.medication.update({ + where: { id: medicationId }, + data: { startsOn: window.startsOn, endsOn: window.endsOn }, + }); + return window; +} + +function refuseIfInvalid( + courses: { startsOn: Date; endsOn: Date | null }[], + todayKey: string, + oneShot: boolean, +) { + const refusal = validateCourses(courses, todayKey, oneShot); + if (refusal) throw new CourseWriteError(refusal); +} + +function encryptNote(note: string | null | undefined) { + if (note === undefined) return undefined; + return note === null || note.trim() === "" ? null : encryptToBytes(note); +} + +interface WriteContext { + userId: string; + medicationId: string; + timeZone: string; + now?: Date; +} + +export async function createCourse( + args: WriteContext & { + startsOn: Date; + endsOn: Date | null; + note?: string | null; + }, +): Promise { + const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); + return prisma.$transaction(async (tx) => { + await lockMedication(tx, args.medicationId); + const { medication, courses } = await loadOwned( + tx, + args.userId, + args.medicationId, + ); + refuseIfInvalid( + [...courses, { startsOn: args.startsOn, endsOn: args.endsOn }], + todayKey, + medication.oneShot, + ); + const created = await tx.medicationCourse.create({ + data: { + medicationId: args.medicationId, + userId: args.userId, + startsOn: args.startsOn, + endsOn: args.endsOn, + noteEncrypted: encryptNote(args.note) ?? null, + }, + select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true }, + }); + await project(tx, args.medicationId); + return created; + }); +} + +export async function updateCourse( + args: WriteContext & { + courseId: string; + startsOn?: Date; + endsOn?: Date | null; + note?: string | null; + }, +): Promise { + const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); + return prisma.$transaction(async (tx) => { + await lockMedication(tx, args.medicationId); + const { medication, courses } = await loadOwned( + tx, + args.userId, + args.medicationId, + ); + const current = courses.find((c) => c.id === args.courseId); + if (!current) throw new CourseWriteError("notFound"); + const next = { + startsOn: args.startsOn ?? current.startsOn, + endsOn: args.endsOn === undefined ? current.endsOn : args.endsOn, + }; + refuseIfInvalid( + courses.map((c) => (c.id === current.id ? next : c)), + todayKey, + medication.oneShot, + ); + const noteEncrypted = encryptNote(args.note); + const updated = await tx.medicationCourse.update({ + where: { id: current.id }, + data: { + startsOn: next.startsOn, + endsOn: next.endsOn, + ...(noteEncrypted !== undefined && { noteEncrypted }), + }, + select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true }, + }); + await project(tx, args.medicationId); + return updated; + }); +} + +/** + * Delete one course. Deleting the only course makes the medication chronic + * again (the projection clears both columns); the UI asks first. + */ +export async function deleteCourse( + args: WriteContext & { courseId: string }, +): Promise { + await prisma.$transaction(async (tx) => { + await lockMedication(tx, args.medicationId); + const { courses } = await loadOwned(tx, args.userId, args.medicationId); + if (!courses.some((c) => c.id === args.courseId)) { + throw new CourseWriteError("notFound"); + } + await tx.medicationCourse.delete({ where: { id: args.courseId } }); + await project(tx, args.medicationId); + }); +} + +/** + * The course a window describes. A window with only an end has always meant + * "running since creation" (or since the end day, when that lies earlier). + */ +function windowAsCourse( + startsOn: Date | null, + endsOn: Date | null, + latest: { startsOn: Date } | undefined, + createdAt: Date, + timeZone: string, +): { startsOn: Date; endsOn: Date | null } { + if (startsOn) return { startsOn, endsOn }; + if (latest) return { startsOn: latest.startsOn, endsOn }; + const creationKey = userDayKey(createdAt, timeZone); + const endKey = endsOn ? dayKeyOfDate(endsOn) : null; + return { + startsOn: dateOfDayKey( + endKey && endKey < creationKey ? endKey : creationKey, + ), + endsOn, + }; +} + +/** + * What `PUT /api/medications/{id}` does with `startsOn` / `endsOn` since + * v1.40: the window the body sets becomes the latest course (created when + * the medication had none), so a client that edits the window as before (the + * iPhone app's course row, the web wizard, "End course") keeps working. The + * two values are the medication's window after the update, with an absent + * field already resolved to its stored value. + * + * Clearing both on a medication with one course deletes it (chronic again, + * the pre-course meaning of no dates); on one with several it is refused, + * because it would silently drop the history. + */ +export async function setCurrentWindow( + args: WriteContext & { startsOn: Date | null; endsOn: Date | null }, +): Promise { + const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); + return prisma.$transaction(async (tx) => { + await lockMedication(tx, args.medicationId); + const { medication, courses } = await loadOwned( + tx, + args.userId, + args.medicationId, + ); + const latest = sortCourses(courses).at(-1); + + if (args.startsOn === null && args.endsOn === null) { + if (!latest) return null; + if (courses.length > 1) throw new CourseWriteError("windowRequired"); + await tx.medicationCourse.delete({ where: { id: latest.id } }); + return project(tx, args.medicationId); + } + + const next = windowAsCourse( + args.startsOn, + args.endsOn, + latest, + medication.createdAt, + args.timeZone, + ); + + if (latest) { + refuseIfInvalid( + courses.map((c) => (c.id === latest.id ? next : c)), + todayKey, + medication.oneShot, + ); + await tx.medicationCourse.update({ + where: { id: latest.id }, + data: next, + }); + } else { + refuseIfInvalid([next], todayKey, medication.oneShot); + await tx.medicationCourse.create({ + data: { + medicationId: args.medicationId, + userId: args.userId, + ...next, + }, + }); + } + return project(tx, args.medicationId); + }); +} + +/** + * Whether the window a PUT is about to write would be refused. Checked + * BEFORE the medication row is updated, so a refused window leaves the row + * as it was. Same rules as `setCurrentWindow`. + */ +export async function checkCurrentWindow( + args: WriteContext & { + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; + }, +): Promise { + const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); + const courses = await prisma.medicationCourse.findMany({ + where: { medicationId: args.medicationId, userId: args.userId }, + orderBy: { startsOn: "asc" }, + select: { id: true, startsOn: true, endsOn: true }, + }); + const latest = courses.at(-1); + if (args.startsOn === null && args.endsOn === null) { + return courses.length > 1 ? "windowRequired" : null; + } + const next = windowAsCourse( + args.startsOn, + args.endsOn, + latest, + args.createdAt, + args.timeZone, + ); + return validateCourses( + latest ? courses.map((c) => (c.id === latest.id ? next : c)) : [next], + todayKey, + args.oneShot, + ); +} + +// ── The read side ────────────────────────────────────────────────────────── + +export interface MedicationCourseDto { + id: string; + startsOn: string; + endsOn: string | null; + status: CourseStatus; + /** Doses logged as taken whose slot falls on a day of this course. */ + takenDoses: number; + /** The person's note on this course, decrypted. */ + note: string | null; +} + +export interface MedicationCourseFields { + courses: MedicationCourseDto[]; + courseCount: number; + previousCourseEndedOn: string | null; + canStartCourse: boolean; +} + +/** Every course of the given medications, ascending by start. */ +export async function loadCoursesFor( + medicationIds: string[], +): Promise> { + const byMedication = new Map(); + if (medicationIds.length === 0) return byMedication; + const rows = await prisma.medicationCourse.findMany({ + where: { medicationId: { in: medicationIds } }, + orderBy: { startsOn: "asc" }, + select: { + id: true, + medicationId: true, + startsOn: true, + endsOn: true, + noteEncrypted: true, + }, + }); + for (const { medicationId, ...row } of rows) { + const list = byMedication.get(medicationId) ?? []; + list.push(row); + byMedication.set(medicationId, list); + } + return byMedication; +} + +/** + * Taken doses per course: intake rows with a `takenAt`, not tombstoned, + * whose scheduled slot falls on a day of the course on the person's clock. + */ +export async function countTakenDosesPerCourse( + medicationIds: string[], + timeZone: string, +): Promise> { + const counts = new Map(); + if (medicationIds.length === 0) return counts; + const rows = await prisma.$queryRaw>` + SELECT c."id", COUNT(e."id")::int AS taken + FROM "medication_courses" c + JOIN "medication_intake_events" e + ON e."medication_id" = c."medication_id" + AND e."taken_at" IS NOT NULL + AND e."deleted_at" IS NULL + AND (e."scheduled_for" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})::date + BETWEEN c."starts_on" AND COALESCE(c."ends_on", 'infinity'::date) + WHERE c."medication_id" = ANY(${medicationIds}::text[]) + GROUP BY c."id" + `; + for (const row of rows) counts.set(row.id, row.taken); + return counts; +} + +/** The resolved course fields the medication list and detail publish. */ +export function buildCourseFields( + medication: { active: boolean; oneShot: boolean }, + courses: readonly CourseRow[], + takenByCourse: ReadonlyMap, + now: Date, + timeZone: string, +): MedicationCourseFields { + const todayKey = userDayKey(now, timeZone); + const ended = previousCourseEndedOn(courses, todayKey); + return { + courses: courses.map((c) => ({ + id: c.id, + startsOn: dayKeyOfDate(c.startsOn), + endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + status: courseStatusOn(c, todayKey), + takenDoses: takenByCourse.get(c.id) ?? 0, + note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null, + })), + courseCount: courses.length, + previousCourseEndedOn: ended ? dayKeyOfDate(ended) : null, + canStartCourse: canStartCourse({ + active: medication.active, + oneShot: medication.oneShot, + courses, + todayKey, + }), + }; +} + +/** The read side for a set of medications, in two queries. */ +export async function resolveCourseFields( + medications: readonly { id: string; active: boolean; oneShot: boolean }[], + now: Date, + timeZone: string, +): Promise> { + const ids = medications.map((m) => m.id); + const [coursesBy, taken] = await Promise.all([ + loadCoursesFor(ids), + countTakenDosesPerCourse(ids, timeZone), + ]); + return new Map( + medications.map((m) => [ + m.id, + buildCourseFields(m, coursesBy.get(m.id) ?? [], taken, now, timeZone), + ]), + ); +} diff --git a/src/lib/medications/efficacy/build-efficacy.ts b/src/lib/medications/efficacy/build-efficacy.ts index 483e8a066..2841c24ad 100644 --- a/src/lib/medications/efficacy/build-efficacy.ts +++ b/src/lib/medications/efficacy/build-efficacy.ts @@ -24,6 +24,7 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, buildMedicationComplianceBundle, dailyComplianceRatesFromLedger, lastNonSkippedTakenAt, @@ -420,6 +421,8 @@ export async function buildMedicationEfficacy( schedules: true, scheduleRevisions: { orderBy: { validFrom: "asc" } }, pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, doseChanges: { orderBy: { effectiveFrom: "asc" } }, }, }); diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index e40eeafb5..bfcf6f033 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -14,6 +14,7 @@ */ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; +import { resolveCourseFields } from "@/lib/medications/courses"; import { resolveMedicationCategories, type ResolvedMedicationCategory, @@ -216,6 +217,8 @@ export async function buildMedicationsList( // the list GET is cached 60 s on userId, so a 60 s staleness window is // accepted here as it already is for `todayEventCount`. const now = new Date(); + // v1.40 (#1024) — every medication's courses, two queries for the list. + const courseFieldsByMed = await resolveCourseFields(medications, now, userTz); return medications.map((m) => { // v1.39.1 (#1033) — intake tracking off: the stored rows are a record, @@ -309,6 +312,10 @@ export async function buildMedicationsList( // v1.40 (#1041) — the decrypted label of a custom category; null for // a built-in one, which every client localises from `category`. categoryLabel: categoryMap[m.id]?.categoryLabel ?? null, + // v1.40 (#1024) — `courses`, `courseCount`, `previousCourseEndedOn`, + // `canStartCourse`. `startsOn`/`endsOn`/`courseStatus` stay the current + // course, which the row projects. + ...courseFieldsByMed.get(m.id), // v1.32.25 — provenance echo. Surfacing the mirror source lets the // web UI and an operator tell an externally-mirrored row (today only // Apple Health) from a native HealthLog medication. NULL for a native diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts index 9a355870c..51ef9c400 100644 --- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -242,6 +242,29 @@ describe("occurrencesBetween — RRULE MONTHLY / YEARLY", () => { // ──────────────────────────────────────────────────────────────────── describe("occurrencesBetween — rolling", () => { + it("does not let an intake from a previous course anchor a new one (v1.40, #1024)", () => { + // The previous course's last dose was months ago; the new course starts + // in two days. Its first dose is due on its start day, not on + // "last intake + 7" (which lies in the past) or on start + 7. + const NOW = d("2026-06-10T12:00:00Z"); + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: d("2026-03-05T07:00:00Z"), + medication: { startsOn: d("2026-06-12T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + NOW, + new Date(NOW.getTime() + 14 * 24 * 60 * 60 * 1000), + ctx, + ); + expect(slots).toHaveLength(1); + expect(slots[0].at.toISOString()).toBe("2026-06-12T06:00:00.000Z"); + }); + it("rollingIntervalDays=7 + lastIntakeAt=T-3d emits one at T+4d", () => { const NOW = d("2026-06-10T12:00:00Z"); const lastIntake = new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1000); diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index fce3930fb..887d3ca95 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -493,14 +493,23 @@ function rollingDoseAnchor( ctx: RecurrenceContext, ): Date { const intervalDays = schedule.rollingIntervalDays ?? 0; + // v1.40 (#1024) — an intake from before the course started (a previous + // course of the same medication) does not anchor this one: the new + // course's first dose is due on its start day, as for a course with no + // intake at all. + const intakeDay = + ctx.lastIntakeAt !== null + ? civilDayOfInstant(ctx.lastIntakeAt, ctx.timeZone) + : null; + const anchorsThisCourse = + intakeDay !== null && + (ctx.medication.startsOn === null || + intakeDay.getTime() >= civilDayOfDate(ctx.medication.startsOn).getTime()); // N calendar days after the local day of the last intake, not N × 24 h: // a late-evening dose across a DST change would otherwise slide onto the // wrong day. - return ctx.lastIntakeAt !== null - ? addCivilDays( - civilDayOfInstant(ctx.lastIntakeAt, ctx.timeZone), - intervalDays, - ) + return anchorsThisCourse + ? addCivilDays(intakeDay, intervalDays) : anchorCivilDay(ctx); } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 4b1cfd108..c69c5c5b0 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -252,6 +252,13 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "medications.category.invalid", "medications.category.limitReached", "medications.category.unknown", + "medications.course.currentOpen", + "medications.course.invalid", + "medications.course.invalidRange", + "medications.course.notFound", + "medications.course.oneShot", + "medications.course.overlap", + "medications.course.windowRequired", "medications.intake.bulk.apple_health_not_mirrored", "medications.intake.bulk.invalid", "medications.intake.bulk.too_large", diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index c4b5d2ba1..5eaeb8528 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -22,6 +22,8 @@ import { updateIntakeEventSchema, createMedicationCategoryLabelSchema, updateMedicationCategoryLabelSchema, + createMedicationCourseSchema, + updateMedicationCourseSchema, } from "@/lib/validations/medication"; import { scheduleRevisionCreateSchema, @@ -76,6 +78,7 @@ const medicationEfficacyResponse = medicationEfficacyResponseSchema.meta({ "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 { + medicationCourseResource, medicationCategoryLabelResource, medicationListEntry, medicationDetailEntry, @@ -1086,6 +1089,143 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/{id}/courses": { + get: { + tags: ["Medications"], + summary: "Read a medication's courses", + description: + "v1.40 (#1024) — the same resolved course fields the list and detail reads carry. Owner-only.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "The courses.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + courses: z.array(medicationCourseResource), + courseCount: z.number().int(), + previousCourseEndedOn: z.iso.date().nullable(), + canStartCourse: z.boolean(), + }), + "MedicationCoursesResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or another user's).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + parameters: [idempotencyKeyParameter], + tags: ["Medications"], + summary: "Start a course", + description: + "v1.40 (#1024) — adds a course and, in the same transaction, rewrites the medication's `startsOn`/`endsOn` to the latest course, so reminders and `intakeActionable` follow with no further write. Refusals: 422 `medications.course.overlap`, `medications.course.currentOpen`, `medications.course.oneShot`, `medications.course.invalidRange`, `medications.course.invalid` (body). Owner-only.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: createMedicationCourseSchema }, + }, + }, + responses: { + ...idempotentWrite(), + "201": { + description: "The new course.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + id: z.string(), + startsOn: z.iso.date(), + endsOn: z.iso.date().nullable(), + }), + "CreateMedicationCourseResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or another user's).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/courses/{courseId}": { + patch: { + tags: ["Medications"], + summary: "Edit a course", + description: + "v1.40 (#1024) — field by field; the medication's window is re-projected in the same transaction. Same refusals as the create. Owner-only.", + requestParams: { + path: z.object({ id: z.string(), courseId: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateMedicationCourseSchema }, + }, + }, + responses: { + "200": { + description: "The edited course.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + id: z.string(), + startsOn: z.iso.date(), + endsOn: z.iso.date().nullable(), + }), + "UpdateMedicationCourseResponse", + ), + }, + }, + }, + "404": { + description: + "Course not found (`medications.course.notFound`), or another user's.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Delete a course", + description: + "v1.40 (#1024) — deletes the course and re-projects the medication's window. Deleting the only course makes the medication continuous again (both window columns cleared). Intakes are not touched. Owner-only.", + requestParams: { + path: z.object({ id: z.string(), courseId: z.string() }), + }, + responses: { + "200": { + description: "Deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ id: z.string(), deleted: z.literal(true) }), + "DeleteMedicationCourseResponse", + ), + }, + }, + }, + "404": { + description: + "Course not found (`medications.course.notFound`), or another user's.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/medications/layout": { get: { tags: ["Medications"], @@ -1195,7 +1335,7 @@ export const medicationPaths: NonNullable = { 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`. 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`.", + "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`. v1.40 (#1024) — `startsOn`/`endsOn` now edit the latest course (created when there is none); the same course refusals apply (422 `medications.course.*`), and clearing both on a medication with several courses is 422 `medications.course.windowRequired`.", requestParams: { path: z.object({ id: z.string() }), }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 905c53cf1..4c54d773f 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -50,6 +50,57 @@ const medicationCategoryLabelField = z "v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in category, which a client localises from `category`. Read-only.", ); +export const medicationCourseResource = z + .object({ + id: z.string(), + startsOn: z.iso + .date() + .describe("First day of the course, inclusive (`YYYY-MM-DD`)."), + endsOn: z.iso + .date() + .nullable() + .describe("Last day, inclusive. NULL = open (only the latest course)."), + status: z + .enum(["UPCOMING", "CURRENT", "ENDED"]) + .describe("Where today, on the person's clock, sits in this course."), + takenDoses: z + .number() + .int() + .nonnegative() + .describe( + "Doses logged as taken whose slot falls on a day of this course.", + ), + note: z + .string() + .nullable() + .describe("The person's note on this course. Encrypted at rest."), + }) + .meta({ + id: "MedicationCourse", + description: + "v1.40 (#1024) — one course of a medication. Courses never overlap and every course except the latest has ended, so the medication's `startsOn`/`endsOn`/`courseStatus`/`intakeActionable` are always those of the latest course.", + }); + +const medicationCourseFields = { + courses: z + .array(medicationCourseResource) + .describe( + "v1.40 — every course, ascending by start. Empty for a medication taken continuously since creation. Server-resolved; render it, do not recompute it.", + ), + courseCount: z.number().int().nonnegative(), + previousCourseEndedOn: z.iso + .date() + .nullable() + .describe( + "v1.40 — the last day of the most recent course that has ended (answers 'when did I last take it'); null when none has.", + ), + canStartCourse: z + .boolean() + .describe( + "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, not one-time, has a course and every course has ended.", + ), +}; + export const medicationCategoryLabelResource = z .object({ key: z @@ -291,6 +342,7 @@ export const medicationListEntry = medicationResource .extend({ category: medicationCategoryEnum, categoryLabel: medicationCategoryLabelField, + ...medicationCourseFields, // 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 @@ -357,6 +409,7 @@ export const medicationDetailEntry = medicationResource .extend({ category: medicationCategoryEnum, categoryLabel: medicationCategoryLabelField, + ...medicationCourseFields, courseStatus: z .enum(["UPCOMING", "CURRENT", "ENDED"]) .describe( diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index f59b14b2b..5e7d3f3b2 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -12,7 +12,10 @@ */ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; -import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; +import { + COURSES_COMPLIANCE_SELECT, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import pLimit from "p-limit"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -186,6 +189,8 @@ export async function buildTargetsResponse(user: AuthedUser) { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, scheduleRevisions: { orderBy: { validFrom: "asc" } }, pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, orderBy: { name: "asc" }, }); diff --git a/src/lib/targets/types.ts b/src/lib/targets/types.ts index 475fd1e20..a92e3a1db 100644 --- a/src/lib/targets/types.ts +++ b/src/lib/targets/types.ts @@ -68,6 +68,8 @@ export interface TargetMedication { schedules: ComplianceSchedule[]; scheduleRevisions?: ScheduleRevisionLike[]; pauseEras?: MedicationPauseEraLike[]; + /** v1.40 (#1024) — the courses, so a gap between two expects nothing. */ + courses?: { startsOn: Date; endsOn: Date | null }[]; } export interface TargetIntakeEvent extends IntakeEvent { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 5bb94f86e..09ff16941 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -212,6 +212,20 @@ const medicationPauseEraSchema = z }) .passthrough(); +/** + * v1.40 (#1024) — one course of a medication: a calendar span (both ends + * inclusive, `YYYY-MM-DD`). The note travels decrypted in both purposes and + * is sealed again on restore, like the custom category label. + */ +const medicationCourseSchema = z + .object({ + startsOn: z.iso.date(), + endsOn: z.iso.date().nullable(), + note: z.string().max(280).nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + /** * One step of a titration: when the dose moved and to what. * @@ -413,6 +427,10 @@ const medicationSchema = z // Same default again. A drug whose plan has never been replaced has no // archived era, and a file written before the eras travelled has no key. scheduleRevisions: z.array(medicationScheduleRevisionSchema).default([]), + // v1.40 (#1024) — NOT defaulted: absent means a file written before + // courses existed, and the restore then derives the one course the + // medication's own window describes, exactly as the migration did. + courses: z.array(medicationCourseSchema).optional(), }) .passthrough(); diff --git a/src/lib/validations/medication/course.ts b/src/lib/validations/medication/course.ts new file mode 100644 index 000000000..f85cf7b67 --- /dev/null +++ b/src/lib/validations/medication/course.ts @@ -0,0 +1,54 @@ +import { z } from "zod/v4"; + +/** + * v1.40 (#1024) — the bodies of `POST /api/medications/{id}/courses` and + * `PATCH /api/medications/{id}/courses/{courseId}`. A course carries a + * calendar span and an optional note; no schedule or dose of its own. + */ +const courseDate = z.iso.date().transform((s) => new Date(s)); + +const courseNote = z + .string() + .trim() + .max(280, "Note must be at most 280 characters") + .nullable(); + +export const createMedicationCourseSchema = z + .object({ + startsOn: courseDate.describe( + "First day of the course (ISO `YYYY-MM-DD`, inclusive, the person's calendar).", + ), + endsOn: courseDate + .nullable() + .optional() + .describe( + "Last day (inclusive). NULL or absent = open; only the latest course may be open.", + ), + note: courseNote + .optional() + .describe("Why this course, in the person's words. Encrypted at rest."), + }) + .meta({ + id: "CreateMedicationCourseRequest", + description: + "v1.40 — start a course. Refused when it shares a day with another course (422 `medications.course.overlap`), when it would sit after a course that has not ended (422 `medications.course.currentOpen`), on a one-time medication that already has one (422 `medications.course.oneShot`), or when it ends before it starts (422 `medications.course.invalidRange`).", + }); + +export const updateMedicationCourseSchema = z + .object({ + startsOn: courseDate.optional(), + endsOn: courseDate.nullable().optional(), + note: courseNote.optional(), + }) + .refine( + (v) => + v.startsOn !== undefined || + v.endsOn !== undefined || + v.note !== undefined, + "At least one field is required", + ) + .meta({ + id: "UpdateMedicationCourseRequest", + description: + "v1.40 — edit a course field by field. Same refusals as the create.", + }); diff --git a/src/lib/validations/medication/index.ts b/src/lib/validations/medication/index.ts index 44c2c4250..e85da9a93 100644 --- a/src/lib/validations/medication/index.ts +++ b/src/lib/validations/medication/index.ts @@ -14,3 +14,4 @@ export * from "./schedule"; export * from "./create-update"; export * from "./intake"; export * from "./inventory"; +export * from "./course"; diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index ec6b9db60..7d3cee7a6 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -226,6 +226,8 @@ const COUNT_BACK: Record< p.medicationCategoryAssignment.count({ where: { medication: { userId } } }), MedicationCategoryLabel: (p, userId) => p.medicationCategoryLabel.count({ where: { userId } }), + MedicationCourse: (p, userId) => + p.medicationCourse.count({ where: { userId } }), MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }), MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }), MoodEntryTagLink: (p, userId) => @@ -411,14 +413,33 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, }); + // Two courses with a gap, and the window the row projects from them (the + // latest). The earlier course exists nowhere else once it has ended. const medication = await prisma.medication.create({ data: { userId: OWNER_ID, name: "Round-trip tablet", dose: "5 mg", + startsOn: AT("2026-06-10T00:00:00.000Z"), + endsOn: AT("2026-06-16T00:00:00.000Z"), schedules: { create: { windowStart: "08:00", windowEnd: "09:00", label: "Morning" }, }, + courses: { + create: [ + { + userId: OWNER_ID, + startsOn: AT("2026-03-01T00:00:00.000Z"), + endsOn: AT("2026-03-07T00:00:00.000Z"), + noteEncrypted: encryptToBytes("the March flu"), + }, + { + userId: OWNER_ID, + startsOn: AT("2026-06-10T00:00:00.000Z"), + endsOn: AT("2026-06-16T00:00:00.000Z"), + }, + ], + }, }, }); await prisma.medicationIntakeEvent.create({ diff --git a/tests/integration/medication-courses.test.ts b/tests/integration/medication-courses.test.ts new file mode 100644 index 000000000..88cddfc83 --- /dev/null +++ b/tests/integration/medication-courses.test.ts @@ -0,0 +1,361 @@ +/** + * Several courses per medication (#1024) against a real Postgres, through the + * shipped route exports and the shipped migration backfill. + * + * What the file pins: + * - the migration's backfill turns every medication window into one course + * (an end-only window starting on the creation day), leaves a medication + * without a window course-less, and is a no-op on a re-run; + * - after every course write (create, edit, delete) the medication row + * carries the latest course as its window, so reminders and the dose + * actions read the right one; + * - overlapping courses, a course queued behind a running one and a second + * course on a one-time medication are refused, and a refused write + * changes nothing; + * - the medication's own PUT edits the latest course; + * - the list and detail reads publish the resolved course fields. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "user-med-courses"; +const D = (key: string) => new Date(`${key}T00:00:00.000Z`); +const key = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : null); + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + await prisma.user.create({ + data: { + id: OWNER, + username: OWNER, + email: `${OWNER}@example.test`, + timezone: "UTC", + }, + }); + const session = await prisma.session.create({ + data: { userId: OWNER, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function req(method: string, path: string, body?: unknown) { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function makeMedication(window: { + startsOn?: string | null; + endsOn?: string | null; + oneShot?: boolean; +}) { + const { POST } = await import("@/app/api/medications/route"); + const res = await POST( + req("POST", "/api/medications", { + name: "Tamiflu", + dose: "75 mg", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + ...window, + }), + ); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(201); + return body.data as { id: string }; +} + +async function postCourse(id: string, body: unknown) { + const { POST } = await import("@/app/api/medications/[id]/courses/route"); + return POST(req("POST", `/api/medications/${id}/courses`, body), { + params: Promise.resolve({ id }), + }); +} + +async function window(id: string) { + const row = await getPrismaClient().medication.findUniqueOrThrow({ + where: { id }, + select: { startsOn: true, endsOn: true }, + }); + return { startsOn: key(row.startsOn), endsOn: key(row.endsOn) }; +} + +async function courses(id: string) { + const rows = await getPrismaClient().medicationCourse.findMany({ + where: { medicationId: id }, + orderBy: { startsOn: "asc" }, + }); + return rows.map((r) => [key(r.startsOn), key(r.endsOn)]); +} + +describe("migration 0368 backfill", () => { + const SQL = readFileSync( + join( + process.cwd(), + "prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql", + ), + "utf8", + ); + const backfill = SQL.slice(SQL.indexOf('INSERT INTO "medication_courses"')); + + it("makes one course of every window and none of no window, idempotently", async () => { + const prisma = getPrismaClient(); + const created = new Date("2026-02-10T09:00:00.000Z"); + const base = { userId: OWNER, dose: "1", createdAt: created }; + const both = await prisma.medication.create({ + data: { + ...base, + name: "both", + startsOn: D("2026-03-01"), + endsOn: D("2026-03-07"), + }, + }); + const startOnly = await prisma.medication.create({ + data: { ...base, name: "start", startsOn: D("2026-03-01") }, + }); + const endOnly = await prisma.medication.create({ + data: { ...base, name: "end", endsOn: D("2026-04-01") }, + }); + const backdated = await prisma.medication.create({ + data: { ...base, name: "backdated", endsOn: D("2026-01-15") }, + }); + const chronic = await prisma.medication.create({ + data: { ...base, name: "chronic" }, + }); + + const eligible = await prisma.medication.count({ + where: { OR: [{ startsOn: { not: null } }, { endsOn: { not: null } }] }, + }); + const inserted = await prisma.$executeRawUnsafe(backfill); + expect(inserted).toBe(eligible); + expect(await prisma.$executeRawUnsafe(backfill)).toBe(0); + + expect(await courses(both.id)).toEqual([["2026-03-01", "2026-03-07"]]); + expect(await courses(startOnly.id)).toEqual([["2026-03-01", null]]); + expect(await courses(endOnly.id)).toEqual([["2026-02-10", "2026-04-01"]]); + expect(await courses(backdated.id)).toEqual([["2026-01-15", "2026-01-15"]]); + expect(await courses(chronic.id)).toEqual([]); + // Data-preserving: the medication columns are untouched. + expect(await window(endOnly.id)).toEqual({ + startsOn: null, + endsOn: "2026-04-01", + }); + }); +}); + +describe("course writes keep the projection", () => { + it("projects the latest course after create, edit and delete", async () => { + const med = await makeMedication({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + expect(await courses(med.id)).toEqual([["2026-03-01", "2026-03-07"]]); + + const created = await postCourse(med.id, { + startsOn: "2026-06-10", + endsOn: "2026-06-16", + note: "flu again", + }); + const createdBody = await created.json(); + expect(created.status, JSON.stringify(createdBody)).toBe(201); + expect(await window(med.id)).toEqual({ + startsOn: "2026-06-10", + endsOn: "2026-06-16", + }); + + const { PATCH, DELETE } = + await import("@/app/api/medications/[id]/courses/[courseId]/route"); + const ctx = { + params: Promise.resolve({ id: med.id, courseId: createdBody.data.id }), + }; + const patched = await PATCH( + req("PATCH", "/x", { endsOn: "2026-06-20" }), + ctx, + ); + expect(patched.status).toBe(200); + expect(await window(med.id)).toEqual({ + startsOn: "2026-06-10", + endsOn: "2026-06-20", + }); + + const deleted = await DELETE(req("DELETE", "/x"), { + params: Promise.resolve({ id: med.id, courseId: createdBody.data.id }), + }); + expect(deleted.status).toBe(200); + expect(await window(med.id)).toEqual({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + + // Deleting the only course makes the medication continuous again. + const [only] = await getPrismaClient().medicationCourse.findMany({ + where: { medicationId: med.id }, + }); + await DELETE(req("DELETE", "/x"), { + params: Promise.resolve({ id: med.id, courseId: only.id }), + }); + expect(await window(med.id)).toEqual({ startsOn: null, endsOn: null }); + }); + + it("refuses an overlap, a queued course and a second one-time course, changing nothing", async () => { + const med = await makeMedication({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + const overlap = await postCourse(med.id, { + startsOn: "2026-03-05", + endsOn: "2026-03-09", + }); + expect(overlap.status).toBe(422); + expect((await overlap.json()).meta.errorCode).toBe( + "medications.course.overlap", + ); + + // A course that has not ended yet cannot have another one queued after + // it, even without sharing a day. + const shift = (days: number) => + new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10); + const running = await makeMedication({ + startsOn: shift(-2), + endsOn: shift(5), + }); + const queued = await postCourse(running.id, { + startsOn: shift(10), + endsOn: shift(12), + }); + expect(queued.status).toBe(422); + expect((await queued.json()).meta.errorCode).toBe( + "medications.course.currentOpen", + ); + + const once = await makeMedication({ + startsOn: "2026-03-01", + oneShot: true, + }); + const second = await postCourse(once.id, { startsOn: "2026-04-01" }); + expect(second.status).toBe(422); + expect((await second.json()).meta.errorCode).toBe( + "medications.course.oneShot", + ); + + expect(await courses(med.id)).toEqual([["2026-03-01", "2026-03-07"]]); + expect(await window(med.id)).toEqual({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + }); + + it("lets the medication's own PUT edit the latest course", async () => { + const med = await makeMedication({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + await postCourse(med.id, { startsOn: "2026-06-10", endsOn: "2026-06-16" }); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${med.id}`, { endsOn: "2026-06-18" }), + { params: Promise.resolve({ id: med.id }) }, + ); + expect(res.status).toBe(200); + expect(await courses(med.id)).toEqual([ + ["2026-03-01", "2026-03-07"], + ["2026-06-10", "2026-06-18"], + ]); + + // Clearing the window would drop the history: refused, nothing changes. + const cleared = await PUT( + req("PUT", `/api/medications/${med.id}`, { + startsOn: null, + endsOn: null, + }), + { params: Promise.resolve({ id: med.id }) }, + ); + expect(cleared.status).toBe(422); + expect((await cleared.json()).meta.errorCode).toBe( + "medications.course.windowRequired", + ); + expect(await window(med.id)).toEqual({ + startsOn: "2026-06-10", + endsOn: "2026-06-18", + }); + }); + + it("publishes the resolved course fields on the list and the detail", async () => { + const med = await makeMedication({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + await postCourse(med.id, { startsOn: "2026-04-01", endsOn: "2026-04-03" }); + await getPrismaClient().medicationIntakeEvent.create({ + data: { + userId: OWNER, + medicationId: med.id, + scheduledFor: new Date("2026-03-02T08:00:00.000Z"), + takenAt: new Date("2026-03-02T08:03:00.000Z"), + }, + }); + + const { GET } = await import("@/app/api/medications/[id]/route"); + const body = await ( + await GET(req("GET", `/api/medications/${med.id}`), { + params: Promise.resolve({ id: med.id }), + }) + ).json(); + expect(body.data).toMatchObject({ + startsOn: expect.stringContaining("2026-04-01"), + courseCount: 2, + previousCourseEndedOn: "2026-04-03", + canStartCourse: true, + courseStatus: "ENDED", + }); + expect( + body.data.courses.map( + (c: { startsOn: string; status: string; takenDoses: number }) => [ + c.startsOn, + c.status, + c.takenDoses, + ], + ), + ).toEqual([ + ["2026-03-01", "ENDED", 1], + ["2026-04-01", "ENDED", 0], + ]); + + const { GET: list } = await import("@/app/api/medications/route"); + const listBody = await (await list()).json(); + expect(listBody.data[0]).toMatchObject({ + courseCount: 2, + canStartCourse: true, + }); + }); +}); From 157002f75db7b1a6adbf2661ea303be0c06c7c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 22:39:28 +0200 Subject: [PATCH 07/18] Translate the medication courses into every locale --- messages/de.json | 41 +++++++++++++++++++++++++++++++++++++++-- messages/en.json | 41 +++++++++++++++++++++++++++++++++++++++-- messages/es.json | 41 +++++++++++++++++++++++++++++++++++++++-- messages/fr.json | 41 +++++++++++++++++++++++++++++++++++++++-- messages/it.json | 41 +++++++++++++++++++++++++++++++++++++++-- messages/ko.json | 41 +++++++++++++++++++++++++++++++++++++++-- messages/pl.json | 41 +++++++++++++++++++++++++++++++++++++++-- 7 files changed, 273 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index 9040d6e0c..7c1686650 100644 --- a/messages/de.json +++ b/messages/de.json @@ -351,7 +351,9 @@ }, "contacts": "Notfallkontakte", "notes": "Weitere Hinweise" - } + }, + "courseRange": "Einnahmephase {start} bis {end}", + "courseSince": "Einnahmephase seit {start}" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -2303,6 +2305,38 @@ "label": "Kategorie", "all": "Alle Kategorien" } + }, + "course": { + "endedOn": "Letzte Einnahmephase endete am {date}", + "nth": "Phase {n}", + "startNew": "Neue Einnahmephase starten", + "editTitle": "Einnahmephase bearbeiten", + "startsOn": "Erster Tag", + "endsOn": "Letzter Tag (optional)", + "note": "Notiz (optional)", + "notePlaceholder": "z. B. die Grippe im März", + "title": "Einnahmephasen", + "helper": "Wann du es genommen hast. Einnahmen werden nur innerhalb einer Phase erwartet.", + "none": "Durchgehend genommen, keine einzelnen Phasen.", + "range": "{start} bis {end}", + "since": "Seit {start}", + "meta": "Phase {n}, {status}, {taken} Einnahmen", + "status": { + "current": "läuft", + "upcoming": "geplant", + "ended": "beendet" + }, + "delete": "Phase löschen", + "deleteTitle": "Diese Einnahmephase löschen?", + "deleteBody": "Die Daten verschwinden, deine erfassten Einnahmen bleiben im Verlauf.", + "deleteLastConfirm": "Das ist die einzige Phase. Ohne sie gilt das Medikament als durchgehend genommen, seit du es angelegt hast. Die erfassten Einnahmen bleiben.", + "error": { + "overlap": "Diese Phase überschneidet sich mit einer anderen.", + "currentOpen": "Beende die laufende Phase, bevor du eine danach anlegst.", + "oneShot": "Ein einmaliges Medikament hat nur eine Phase.", + "invalidRange": "Der letzte Tag kann nicht vor dem ersten liegen.", + "generic": "Das wurde nicht gespeichert. Bitte versuch es noch einmal." + } } }, "charts": { @@ -8362,7 +8396,10 @@ "doses": { "title": "Erfasste Dosen", "showing": "Angezeigt werden die {shown} jüngsten von {total} erfassten Dosen." - } + }, + "course": "Phase {n}", + "courseRange": "{start} bis {end}", + "courseSince": "seit {start}" }, "cycle": { "symptomCategory": { diff --git a/messages/en.json b/messages/en.json index 387453d17..fafb1473d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -351,7 +351,9 @@ "immunizationsColDate": "Date", "immunizationsColVaccine": "Vaccine", "immunizationsColDose": "Dose", - "immunizationsColLot": "Lot" + "immunizationsColLot": "Lot", + "courseRange": "Course {start} to {end}", + "courseSince": "Course since {start}" }, "errorBoundary": { "title": "Something went wrong", @@ -2303,6 +2305,38 @@ "label": "Category", "all": "All categories" } + }, + "course": { + "endedOn": "Last course ended {date}", + "nth": "Course {n}", + "startNew": "Start a new course", + "editTitle": "Edit course", + "startsOn": "First day", + "endsOn": "Last day (optional)", + "note": "Note (optional)", + "notePlaceholder": "e.g. the flu in March", + "title": "Courses", + "helper": "When you took it. Doses are expected only inside a course.", + "none": "Taken continuously, no separate courses.", + "range": "{start} to {end}", + "since": "Since {start}", + "meta": "Course {n}, {status}, {taken} doses taken", + "status": { + "current": "running", + "upcoming": "upcoming", + "ended": "ended" + }, + "delete": "Delete course", + "deleteTitle": "Delete this course?", + "deleteBody": "The dates go; the doses you logged stay in the history.", + "deleteLastConfirm": "This is the only course. Without it the medication counts as taken continuously since you added it. The logged doses stay.", + "error": { + "overlap": "This course shares days with another one.", + "currentOpen": "End the running course before adding one after it.", + "oneShot": "A one-time medication has a single course.", + "invalidRange": "The last day cannot be before the first.", + "generic": "That did not save. Please try again." + } } }, "charts": { @@ -8362,7 +8396,10 @@ "doses": { "title": "Logged doses", "showing": "Showing the {shown} most recent of {total} logged doses." - } + }, + "course": "Course {n}", + "courseRange": "{start} to {end}", + "courseSince": "since {start}" }, "cycle": { "symptomCategory": { diff --git a/messages/es.json b/messages/es.json index cbbbcfcf1..aafe4581b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -351,7 +351,9 @@ }, "contacts": "Contactos de emergencia", "notes": "Notas adicionales" - } + }, + "courseRange": "Tratamiento del {start} al {end}", + "courseSince": "Tratamiento desde el {start}" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -2303,6 +2305,38 @@ "label": "Categoría", "all": "Todas las categorías" } + }, + "course": { + "endedOn": "El último tratamiento terminó el {date}", + "nth": "Tratamiento {n}", + "startNew": "Empezar un nuevo tratamiento", + "editTitle": "Editar tratamiento", + "startsOn": "Primer día", + "endsOn": "Último día (opcional)", + "note": "Nota (opcional)", + "notePlaceholder": "p. ej. la gripe de marzo", + "title": "Tratamientos", + "helper": "Cuándo lo tomaste. Solo se esperan tomas dentro de un tratamiento.", + "none": "Tomado de forma continua, sin tratamientos separados.", + "range": "Del {start} al {end}", + "since": "Desde el {start}", + "meta": "Tratamiento {n}, {status}, {taken} tomas", + "status": { + "current": "en curso", + "upcoming": "previsto", + "ended": "terminado" + }, + "delete": "Eliminar tratamiento", + "deleteTitle": "¿Eliminar este tratamiento?", + "deleteBody": "Se eliminan las fechas; las tomas registradas siguen en el historial.", + "deleteLastConfirm": "Es el único tratamiento. Sin él, el medicamento cuenta como tomado de forma continua desde que lo añadiste. Las tomas registradas se conservan.", + "error": { + "overlap": "Este tratamiento coincide en días con otro.", + "currentOpen": "Termina el tratamiento en curso antes de añadir uno posterior.", + "oneShot": "Un medicamento de una sola vez tiene un único tratamiento.", + "invalidRange": "El último día no puede ser anterior al primero.", + "generic": "No se ha guardado. Inténtalo de nuevo." + } } }, "charts": { @@ -8362,7 +8396,10 @@ "doses": { "title": "Dosis registradas", "showing": "Se muestran las {shown} dosis más recientes de {total} registradas." - } + }, + "course": "Tratamiento {n}", + "courseRange": "del {start} al {end}", + "courseSince": "desde el {start}" }, "cycle": { "symptomCategory": { diff --git a/messages/fr.json b/messages/fr.json index ecf204b16..f50881afb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -351,7 +351,9 @@ }, "contacts": "Contacts d'urgence", "notes": "Notes complémentaires" - } + }, + "courseRange": "Traitement du {start} au {end}", + "courseSince": "Traitement depuis le {start}" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -2303,6 +2305,38 @@ "label": "Catégorie", "all": "Toutes les catégories" } + }, + "course": { + "endedOn": "Dernier traitement terminé le {date}", + "nth": "Traitement {n}", + "startNew": "Commencer un nouveau traitement", + "editTitle": "Modifier le traitement", + "startsOn": "Premier jour", + "endsOn": "Dernier jour (facultatif)", + "note": "Note (facultatif)", + "notePlaceholder": "p. ex. la grippe de mars", + "title": "Traitements", + "helper": "Quand tu l’as pris. Les prises ne sont attendues que pendant un traitement.", + "none": "Pris en continu, sans traitements séparés.", + "range": "Du {start} au {end}", + "since": "Depuis le {start}", + "meta": "Traitement {n}, {status}, {taken} prises", + "status": { + "current": "en cours", + "upcoming": "à venir", + "ended": "terminé" + }, + "delete": "Supprimer le traitement", + "deleteTitle": "Supprimer ce traitement ?", + "deleteBody": "Les dates disparaissent ; les prises enregistrées restent dans l’historique.", + "deleteLastConfirm": "C’est le seul traitement. Sans lui, le médicament compte comme pris en continu depuis son ajout. Les prises enregistrées restent.", + "error": { + "overlap": "Ce traitement chevauche un autre.", + "currentOpen": "Termine le traitement en cours avant d’en ajouter un après.", + "oneShot": "Un médicament ponctuel n’a qu’un seul traitement.", + "invalidRange": "Le dernier jour ne peut pas précéder le premier.", + "generic": "L’enregistrement a échoué. Réessaie." + } } }, "charts": { @@ -8362,7 +8396,10 @@ "doses": { "title": "Doses enregistrées", "showing": "Affichage des {shown} doses les plus récentes sur {total} enregistrées." - } + }, + "course": "Traitement {n}", + "courseRange": "du {start} au {end}", + "courseSince": "depuis le {start}" }, "cycle": { "symptomCategory": { diff --git a/messages/it.json b/messages/it.json index 310c84d24..5c41b39fb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -351,7 +351,9 @@ }, "contacts": "Contatti di emergenza", "notes": "Note aggiuntive" - } + }, + "courseRange": "Ciclo dal {start} al {end}", + "courseSince": "Ciclo dal {start}" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -2303,6 +2305,38 @@ "label": "Categoria", "all": "Tutte le categorie" } + }, + "course": { + "endedOn": "Ultimo ciclo terminato il {date}", + "nth": "Ciclo {n}", + "startNew": "Inizia un nuovo ciclo", + "editTitle": "Modifica ciclo", + "startsOn": "Primo giorno", + "endsOn": "Ultimo giorno (facoltativo)", + "note": "Nota (facoltativa)", + "notePlaceholder": "es. l’influenza di marzo", + "title": "Cicli", + "helper": "Quando l’hai preso. Le assunzioni sono previste solo dentro un ciclo.", + "none": "Preso in modo continuo, senza cicli separati.", + "range": "Dal {start} al {end}", + "since": "Dal {start}", + "meta": "Ciclo {n}, {status}, {taken} assunzioni", + "status": { + "current": "in corso", + "upcoming": "previsto", + "ended": "concluso" + }, + "delete": "Elimina ciclo", + "deleteTitle": "Eliminare questo ciclo?", + "deleteBody": "Le date vengono rimosse; le assunzioni registrate restano nello storico.", + "deleteLastConfirm": "È l’unico ciclo. Senza di esso il farmaco risulta preso in modo continuo da quando l’hai aggiunto. Le assunzioni registrate restano.", + "error": { + "overlap": "Questo ciclo si sovrappone a un altro.", + "currentOpen": "Concludi il ciclo in corso prima di aggiungerne uno dopo.", + "oneShot": "Un farmaco una tantum ha un solo ciclo.", + "invalidRange": "L’ultimo giorno non può precedere il primo.", + "generic": "Il salvataggio non è riuscito. Riprova." + } } }, "charts": { @@ -8362,7 +8396,10 @@ "doses": { "title": "Dosi registrate", "showing": "Sono mostrate le {shown} dosi più recenti su {total} registrate." - } + }, + "course": "Ciclo {n}", + "courseRange": "dal {start} al {end}", + "courseSince": "dal {start}" }, "cycle": { "symptomCategory": { diff --git a/messages/ko.json b/messages/ko.json index c225b1dbc..b3f504a0f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -351,7 +351,9 @@ "immunizationsColDate": "날짜", "immunizationsColVaccine": "백신", "immunizationsColDose": "차수", - "immunizationsColLot": "로트번호" + "immunizationsColLot": "로트번호", + "courseRange": "복용 기간 {start}부터 {end}까지", + "courseSince": "복용 기간 {start}부터" }, "errorBoundary": { "title": "문제가 생겼어요", @@ -2303,6 +2305,38 @@ "label": "카테고리", "all": "모든 카테고리" } + }, + "course": { + "endedOn": "마지막 복용 기간이 {date}에 끝났어요", + "nth": "복용 기간 {n}", + "startNew": "새 복용 기간 시작", + "editTitle": "복용 기간 수정", + "startsOn": "첫날", + "endsOn": "마지막 날 (선택)", + "note": "메모 (선택)", + "notePlaceholder": "예: 3월 독감", + "title": "복용 기간", + "helper": "언제 복용했는지예요. 복용은 기간 안에서만 예정돼요.", + "none": "계속 복용 중이고, 따로 나눈 기간은 없어요.", + "range": "{start}부터 {end}까지", + "since": "{start}부터", + "meta": "복용 기간 {n}, {status}, {taken}회 복용", + "status": { + "current": "진행 중", + "upcoming": "예정", + "ended": "종료" + }, + "delete": "복용 기간 삭제", + "deleteTitle": "이 복용 기간을 삭제할까요?", + "deleteBody": "날짜만 삭제되고, 기록한 복용은 기록에 남아요.", + "deleteLastConfirm": "유일한 복용 기간이에요. 삭제하면 이 약은 추가한 날부터 계속 복용한 것으로 봐요. 기록한 복용은 남아요.", + "error": { + "overlap": "이 기간이 다른 기간과 겹쳐요.", + "currentOpen": "진행 중인 기간을 끝낸 뒤에 다음 기간을 추가해 주세요.", + "oneShot": "일회성 약은 복용 기간이 하나뿐이에요.", + "invalidRange": "마지막 날은 첫날보다 앞설 수 없어요.", + "generic": "저장하지 못했어요. 다시 시도해 주세요." + } } }, "charts": { @@ -8362,7 +8396,10 @@ "doses": { "title": "기록된 복용", "showing": "기록된 복용 {total}건 중 최근 {shown}건을 보여줘요." - } + }, + "course": "복용 기간 {n}", + "courseRange": "{start}부터 {end}까지", + "courseSince": "{start}부터" }, "cycle": { "symptomCategory": { diff --git a/messages/pl.json b/messages/pl.json index 363501fcf..be5166a5f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -351,7 +351,9 @@ }, "contacts": "Kontakty w nagłych wypadkach", "notes": "Dodatkowe uwagi" - } + }, + "courseRange": "Kuracja od {start} do {end}", + "courseSince": "Kuracja od {start}" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -2303,6 +2305,38 @@ "label": "Kategoria", "all": "Wszystkie kategorie" } + }, + "course": { + "endedOn": "Ostatnia kuracja zakończyła się {date}", + "nth": "Kuracja {n}", + "startNew": "Rozpocznij nową kurację", + "editTitle": "Edytuj kurację", + "startsOn": "Pierwszy dzień", + "endsOn": "Ostatni dzień (opcjonalnie)", + "note": "Notatka (opcjonalnie)", + "notePlaceholder": "np. grypa w marcu", + "title": "Kuracje", + "helper": "Kiedy go przyjmowano. Dawki są oczekiwane tylko w trakcie kuracji.", + "none": "Przyjmowany stale, bez osobnych kuracji.", + "range": "Od {start} do {end}", + "since": "Od {start}", + "meta": "Kuracja {n}, {status}, przyjęte dawki: {taken}", + "status": { + "current": "trwa", + "upcoming": "zaplanowana", + "ended": "zakończona" + }, + "delete": "Usuń kurację", + "deleteTitle": "Usunąć tę kurację?", + "deleteBody": "Daty zostaną usunięte; zapisane dawki zostaną w historii.", + "deleteLastConfirm": "To jedyna kuracja. Bez niej lek liczy się jako przyjmowany stale od dodania. Zapisane dawki zostaną.", + "error": { + "overlap": "Ta kuracja pokrywa się z inną.", + "currentOpen": "Zakończ trwającą kurację, zanim dodasz kolejną po niej.", + "oneShot": "Lek jednorazowy ma tylko jedną kurację.", + "invalidRange": "Ostatni dzień nie może być przed pierwszym.", + "generic": "Nie udało się zapisać. Spróbuj ponownie." + } } }, "charts": { @@ -8362,7 +8396,10 @@ "doses": { "title": "Zarejestrowane dawki", "showing": "Pokazano {shown} najnowszych z {total} zarejestrowanych dawek." - } + }, + "course": "Kuracja {n}", + "courseRange": "od {start} do {end}", + "courseSince": "od {start}" }, "cycle": { "symptomCategory": { From 4d7abdd12bd835ebe060fd12ddd8142f6dbf0fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 22:47:09 +0200 Subject: [PATCH 08/18] Give a category its own line in the manage sheet At phone width the three actions left the label a few characters; the count and the hidden state now sit under it, the course list's row anatomy. --- .../medication-categories-sheet.tsx | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/components/medications/medication-categories-sheet.tsx b/src/components/medications/medication-categories-sheet.tsx index 4e438ca63..17c53309f 100644 --- a/src/components/medications/medication-categories-sheet.tsx +++ b/src/components/medications/medication-categories-sheet.tsx @@ -20,7 +20,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { QueryErrorRow } from "@/components/ui/query-error-row"; @@ -134,19 +133,23 @@ function CategoryRow({ className="text-muted-foreground size-4 shrink-0" aria-hidden="true" /> - - {category.label} - - {!category.isActive && ( - - {t("medications.category.custom.hidden")} - - )} - - {t("medications.category.custom.count", { - count: String(category.medicationCount), - })} - + {/* Label on its own line with the count and the hidden state under + it, the course list's row anatomy: three 44 px actions leave a + 390 px row too little width to share with a meta column. */} +
+

+ {category.label} +

+

+ {category.isActive + ? t("medications.category.custom.count", { + count: String(category.medicationCount), + }) + : `${t("medications.category.custom.count", { + count: String(category.medicationCount), + })}, ${t("medications.category.custom.hidden")}`} +

+