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