diff --git a/messages/de.json b/messages/de.json index 1c548a69d..ffdc1bf2d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Befundübernahme", "docsLink": "Dokumentation", "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung", - "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens" + "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens", + "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 27ec0d130..ab5924367 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Fact commitment", "docsLink": "Documentation", "restoreSkippedPersonalRecordReference": "Personal best provenance", - "restoreSkippedEcgReference": "ECG strip provenance" + "restoreSkippedEcgReference": "ECG strip provenance", + "restoreSkippedMedicationTarget": "Medication target analyte" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 3bda9d5aa..02d3bba88 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Registro del dato", "docsLink": "Documentación", "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal", - "restoreSkippedEcgReference": "Procedencia de un trazado de ECG" + "restoreSkippedEcgReference": "Procedencia de un trazado de ECG", + "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 109c14a65..ad7830b76 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Enregistrement de la donnée", "docsLink": "Documentation", "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel", - "restoreSkippedEcgReference": "Provenance d'un tracé ECG" + "restoreSkippedEcgReference": "Provenance d'un tracé ECG", + "restoreSkippedMedicationTarget": "Analyte cible d'un médicament" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 92d62e3de..606aee262 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Registrazione del dato", "docsLink": "Documentazione", "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale", - "restoreSkippedEcgReference": "Provenienza di un tracciato ECG" + "restoreSkippedEcgReference": "Provenienza di un tracciato ECG", + "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/pl.json b/messages/pl.json index 983ff3ed8..84dfdf332 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6920,7 +6920,8 @@ "restoreSkippedFactCommitment": "Zapis faktu", "docsLink": "Dokumentacja", "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego", - "restoreSkippedEcgReference": "Pochodzenie zapisu EKG" + "restoreSkippedEcgReference": "Pochodzenie zapisu EKG", + "restoreSkippedMedicationTarget": "Analit docelowy leku" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3c35f2952..5e4a31c63 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -566,6 +566,17 @@ const handler = apiHandler( const medByName = new Map(); const restoredMedicationIds = new Set(); + // The efficacy targets cannot ride inside the medication create + // beside the schedules and the packs: `biomarkerId` is a foreign key + // into a catalogue this transaction has not restored yet. They are + // collected here against the id the drug ACTUALLY got and written in + // a second pass once the biomarkers exist; the pass itself says why + // hoisting the biomarker restore above the medications is the worse + // of the two orderings. + const pendingEfficacyTargets: Array<{ + medicationId: string; + target: (typeof payload.medications)[number]["efficacyTargets"][number]; + }> = []; for (const m of payload.medications) { const created = await tx.medication.create({ data: { @@ -771,6 +782,12 @@ const handler = apiHandler( }); restoredMedicationIds.add(created.id); if (!medByName.has(m.name)) medByName.set(m.name, created.id); + for (const target of m.efficacyTargets) { + pendingEfficacyTargets.push({ + medicationId: created.id, + target, + }); + } } if (payload.intakeEvents.length > 0) { @@ -1230,6 +1247,65 @@ const handler = apiHandler( // `committedRecordId` names the lab result it was committed to, and // that reference is resolved against the rows this loop writes. const restoredLabResultIds = new Set(); + // What each drug was supposed to move, written now that both ends + // exist. The drugs came back several hundred lines up and the + // analytes only just now, and one of those two had to move for a + // target to reach the database at all. + // + // The second pass wins over hoisting the biomarker restore above the + // medications, for two reasons. `biomarkerByName` and + // `restoredBiomarkerIds` are read by the lab-result loop directly + // below, so moving the block would separate a map from its only + // other consumer and leave a future edit free to re-order them back + // without anything noticing. And the direction of the dependency + // would read backwards: a pinned target is a leaf of the medication + // tree, and letting a leaf dictate where the clinical record is + // rebuilt inverts what the file is about. A second pass states the + // constraint where it applies instead of hiding it in a line order. + // + // A named analyte the restore did not put back cannot be written: + // `biomarkerId` is a real foreign key, so a dangling value would + // fail the constraint and roll the entire account back over one + // override. The row is dropped and NAMED. Dropping rather than + // nulling is deliberate here: the override IS the reference, and a + // row with neither arm set is what the resolver already reads as "no + // override", so keeping it would restore something that means + // nothing while still claiming the primary slot. A target that + // carried no name in the first place is a genuine live state (the + // analyte was deleted before the export) and comes back as written. + const unresolvedTargetAnalytes: string[] = []; + const writableTargets = pendingEfficacyTargets.filter((pending) => { + const name = pending.target.biomarkerName; + if (!name || biomarkerByName.has(name)) return true; + unresolvedTargetAnalytes.push(name); + return false; + }); + if (writableTargets.length > 0) { + await tx.medicationEfficacyTarget.createMany({ + data: writableTargets.map(({ medicationId, target }) => ({ + ...(target.id ? { id: target.id } : {}), + medicationId, + measurementType: target.measurementType ?? null, + biomarkerId: target.biomarkerName + ? (biomarkerByName.get(target.biomarkerName) ?? null) + : null, + primary: target.primary ?? true, + ...(target.createdAt + ? { createdAt: new Date(target.createdAt) } + : {}), + ...(target.updatedAt + ? { updatedAt: new Date(target.updatedAt) } + : {}), + })), + }); + } + recordUnknownKeys( + skips, + "medicationTarget", + [...new Set(unresolvedTargetAnalytes)], + unresolvedTargetAnalytes, + ); + for (const lab of payload.labResults) { const biomarkerId = lab.biomarkerId !== undefined diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index a50a0d593..ce0575f7e 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -359,6 +359,9 @@ function catalogueLabel( if (catalogue === "ecgReference") { return t("admin.section.backups.restoreSkippedEcgReference"); } + if (catalogue === "medicationTarget") { + return t("admin.section.backups.restoreSkippedMedicationTarget"); + } // Not a fallback: the chain above is exhaustive and this line is what makes // the compiler say so. A catalogue added without a label here now stops the // build instead of shipping under a label that belongs to something else, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 7f0e37459..04a99afee 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -236,6 +236,18 @@ function makePrisma() { redValue: 180, redMode: "MINUTES", }, + // One pinned efficacy target, on the lab arm. The biomarker rides + // as a NAME, the way a lab result's cross-reference does. + efficacyTargets: [ + { + id: "target-canonical", + measurementType: null, + biomarker: { name: "Ferritin" }, + primary: true, + createdAt: new Date("2026-07-01T08:00:00.000Z"), + updatedAt: new Date("2026-07-01T08:00:00.000Z"), + }, + ], inventoryEvents: [ { id: "stock-in", diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 10773ca00..dde330dc7 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -454,6 +454,18 @@ export const TWO_ENDED_MODELS = [ // fortnight at home, days after a restore that reported success. "EnvironmentContext", "EnvironmentTravelLocation", + // What each drug was supposed to move. The register said the drug comes + // back with no statement of what it was for, and that is exactly the row: + // one exists ONLY where the person overrode the derived ATC / name + // resolution, so it is a decision and never a computation. + // + // It travels by biomarker NAME rather than by id, the way a lab result's + // cross-reference does, because a portable restore mints a fresh catalogue + // id and `(userId, name)` is what the catalogue is unique on. The reference + // is a real foreign key, so a name the restore cannot resolve is dropped and + // reported rather than written — a dangling id here would fail the + // constraint and cost the whole account. + "MedicationEfficacyTarget", // The ECG strips. The register said the originating device may still hold // them, which is true for about as long as the phone beside it does — and // is not true at all once a Withings connection is revoked, because the @@ -506,8 +518,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { export const COVERAGE_PENDING: Readonly> = { MedicationScheduleRevision: "The history of how a schedule changed. Without it a restored medication keeps today's schedule and loses the record of when the dose or timing moved, which is the part a doctor asks about.", - MedicationEfficacyTarget: - "What a medication was supposed to move, and by how much. The drug comes back with no statement of what it was for.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index c3d8a56c5..396f3504b 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -128,6 +128,7 @@ export interface FullBackupCounts medicationInventoryItems: number; medicationInventoryEvents: number; reminderPhaseConfigs: number; + medicationEfficacyTargets: number; moodEntries: number; customMoodTagCategories: number; hiddenMoodTags: number; @@ -425,6 +426,14 @@ export async function buildFullBackupPayload( inventoryItems: { orderBy: { createdAt: "asc" } }, inventoryEvents: { orderBy: { occurredAt: "asc" } }, phaseConfig: true, + // What the drug was supposed to move. The biomarker is read by NAME + // rather than by id, exactly as the lab results beside it are: a + // portable restore mints a fresh biomarker id, and `(userId, name)` is + // the natural key the catalogue is already unique on. + efficacyTargets: { + orderBy: { createdAt: "asc" }, + include: { biomarker: { select: { name: true } } }, + }, }, }), prisma.medicationIntakeEvent.findMany({ @@ -797,6 +806,29 @@ export async function buildFullBackupPayload( reason: e.reason, occurredAt: e.occurredAt.toISOString(), })), + // What the drug is FOR, in the person's own words rather than the + // resolver's. A row exists only where they overrode the derived + // ATC / name inference, so it is the one durable statement of intent + // the "Wirkung" view holds — and the drug restores with no statement of + // what it was for without it. + // + // The lab arm travels as a NAME, the way a lab result's does. The id is + // fresh on a portable restore, and `Biomarker` is unique on + // `(userId, name)`, so the name is the only thing that survives the + // trip. It is resolved on the way back against the biomarkers the + // restore wrote. + efficacyTargets: m.efficacyTargets.map((t) => ({ + ...(disasterRecovery + ? { + id: t.id, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + } + : {}), + measurementType: t.measurementType, + biomarkerName: t.biomarker?.name ?? null, + primary: t.primary, + })), })), intakeEvents: intakeEvents.map((e) => ({ ...(disasterRecovery @@ -998,6 +1030,10 @@ export async function buildFullBackupPayload( 0, ), reminderPhaseConfigs: medications.filter((m) => m.phaseConfig).length, + medicationEfficacyTargets: medications.reduce( + (total, m) => total + m.efficacyTargets.length, + 0, + ), moodEntries: moodEntries.length, customMoodTagCategories: customMoodTagCategories.length, hiddenMoodTags: hiddenMoodTags.length, diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 38927a546..0a110e7d5 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -113,7 +113,17 @@ * omits soft-deleted measurements, which is the ordinary way a live recording * ends up naming one the file does not carry. * - * The eleventh, `checkupClosure`, is not about a restore at all, and it borrows + * The eleventh, `medicationTarget`, names the lab analyte a medication's + * pinned efficacy target pointed at that the restore did not put back. It is + * the one kind here where the ROW is dropped rather than the pointer, and the + * reason is what the row is: an override exists only to say "this drug is for + * that analyte", so an override with nothing on the far side states nothing. + * The resolver already treats a target with neither arm set as "no override", + * so writing one back would restore a row that means nothing and still claims + * the primary slot. The drug, its schedule and its whole history come back; + * what is lost is one statement of intent, and the operator is told which. + * + * The twelfth, `checkupClosure`, is not about a restore at all, and it borrows * this shape deliberately rather than growing a second reporting mechanism * beside it. The situation is the same one: something a write was asked to do * could not be done, the record itself survives, and the person is told which @@ -136,6 +146,7 @@ export type SkippedCatalogue = | "factCommitment" | "personalRecordReference" | "ecgReference" + | "medicationTarget" | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 77c8caa62..ddc768dad 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -264,6 +264,27 @@ const reminderPhaseConfigSchema = z }) .passthrough(); +/** + * What a medication was supposed to move. + * + * The lab arm is a biomarker NAME rather than an id, the way a lab result's + * cross-reference is: the id is fresh on a portable restore, and `Biomarker` + * is unique on `(userId, name)`. Both arms are nullable because the live + * schema produces a row with neither — deleting a biomarker sets the column + * NULL and leaves the override behind as an orphan the resolver reads as "no + * override". + */ +const medicationEfficacyTargetSchema = z + .object({ + id: z.string().min(1).optional(), + measurementType: z.enum(MeasurementType).nullable().optional(), + biomarkerName: z.string().min(1).nullable().optional(), + primary: z.boolean().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -307,6 +328,9 @@ const medicationSchema = z inventoryItems: z.array(medicationInventoryItemSchema).default([]), inventoryEvents: z.array(medicationInventoryEventSchema).default([]), phaseConfig: reminderPhaseConfigSchema.nullable().optional(), + // Same default, same reason: an older file parses, and a drug whose + // target the resolver derives rather than the person pinning it writes []. + efficacyTargets: z.array(medicationEfficacyTargetSchema).default([]), }) .passthrough(); @@ -1697,6 +1721,8 @@ export interface BackupSummary { intakeEvents: number; /** Side effects recorded against a drug, across every medication. */ medicationSideEffects: number; + /** Pinned efficacy targets, across every medication. */ + medicationEfficacyTargets: number; moodEntries: number; /** v1.15.0 — observed cycle spans in the backup. */ cycles: number; @@ -1787,6 +1813,10 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { (sum, medication) => sum + medication.sideEffects.length, 0, ), + medicationEfficacyTargets: payload.medications.reduce( + (sum, medication) => sum + medication.efficacyTargets.length, + 0, + ), moodEntries: payload.moodEntries.length, cycles: payload.cycles.length, cycleDayLogs: payload.cycleDayLogs.length, diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 56a617423..b785734cf 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -229,6 +229,9 @@ const COUNT_BACK: Record< EnvironmentTravelLocation: (p, userId) => p.environmentTravelLocation.count({ where: { userId } }), EcgRecording: (p, userId) => p.ecgRecording.count({ where: { userId } }), + // No own `userId` column — reached through the drug, like the schedules. + MedicationEfficacyTarget: (p, userId) => + p.medicationEfficacyTarget.count({ where: { medication: { userId } } }), }; /** @@ -551,6 +554,17 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { takenAt: AT("2026-06-30T09:00:00.000Z"), }, }); + // What the drug above is FOR, pinned to the analyte just created. The two + // rows are seeded at opposite ends of this fixture on purpose: the drug is + // restored hundreds of lines before the analyte, so this pairing is what + // proves the second pass runs late enough to resolve the name. + await prisma.medicationEfficacyTarget.create({ + data: { + medicationId: medication.id, + biomarkerId: biomarker.id, + primary: true, + }, + }); await prisma.allergy.create({ data: { userId: OWNER_ID, substance: "Penicillin" }, }); @@ -1804,6 +1818,45 @@ describe("every model the plan claims two-ended survives a real restore", () => }, }); + // The pinned target, and the analyte it points at. + // + // Counting says one override returned. It cannot say the override still + // MEANS anything: a restore that wrote the row with `biomarkerId` nulled + // returns a drug with no statement of what it was for, which is precisely + // what the register said the alternative cost, and nothing anywhere would + // complain — the resolver silently falls back to deriving a target and the + // page still renders. + // + // Both ends are resolved from the DATABASE rather than remembered from the + // fixture. The property the second pass owes is that the target points at + // the biomarker THIS restore wrote, which an id carried over from the + // seeding would not distinguish from a restore that copied a stale value. + const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + const restoredMedication = await prisma.medication.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + const target = await prisma.medicationEfficacyTarget.findFirstOrThrow({ + where: { medication: { userId: OWNER_ID } }, + include: { biomarker: { select: { name: true, userId: true } } }, + }); + expect({ + medicationId: target.medicationId, + biomarkerId: target.biomarkerId, + biomarkerName: target.biomarker?.name ?? null, + biomarkerOwner: target.biomarker?.userId ?? null, + measurementType: target.measurementType, + primary: target.primary, + }).toEqual({ + medicationId: restoredMedication.id, + biomarkerId: restoredBiomarker.id, + biomarkerName: "Ferritin", + biomarkerOwner: OWNER_ID, + measurementType: null, + primary: true, + }); + // The strip, read back column by column. // // Counting proves a recording returned. It cannot tell that recording from