From 271f0ed28a1386a845663ea8cc139fc9426021c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 16:11:45 +0200 Subject: [PATCH] fix(medications): make import replay results honest --- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- .../intake/import/__tests__/route.test.ts | 4 + .../medications/[id]/intake/import/route.ts | 2 + .../__tests__/route.test.ts | 4 + .../intake/dose-history-import/route.ts | 2 + .../__tests__/intake-import-result.test.tsx | 49 +++++ .../medications/intake-import-result.tsx | 42 +++++ .../dose-history-import-card.test.tsx | 26 ++- .../import-panel/dose-history-import-card.tsx | 11 +- .../import-panel/dose-history-verdict.tsx | 9 +- .../medication-intake-import.test.ts | 24 +++ src/lib/jobs/medication-intake-import.ts | 177 +++++++++++++++++- .../intake-import-job-status.test.ts | 82 ++++++++ .../__tests__/intake-import-poll.test.ts | 4 + .../import/__tests__/auto-export-plan.test.ts | 21 +++ .../medications/import/auto-export-plan.ts | 30 ++- .../medications/intake-import-job-status.ts | 89 ++++++++- src/lib/medications/intake-import-payload.ts | 4 +- src/lib/medications/intake-import-poll.ts | 3 + .../medication-dose-history-import.test.ts | 15 +- 25 files changed, 607 insertions(+), 27 deletions(-) create mode 100644 src/lib/medications/__tests__/intake-import-job-status.test.ts diff --git a/messages/de.json b/messages/de.json index 154d3616a..33381547f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1942,7 +1942,11 @@ "resultSuccess": "{imported} Einnahmen importiert.", "resultPartial": "{imported} importiert, {skipped} übersprungen.", "resultNothing": "Nichts importiert: alle {skipped} Einträge wurden übersprungen.", + "resultAlreadyRecorded": "{recorded} Einnahmen sind bereits erfasst. Es wurde nichts Neues hinzugefügt.", "resultEmpty": "Diese Datei enthielt keine Einnahmen.", + "showSkipDetails": "Übersprungene Zeilen anzeigen ({count})", + "skipDetailLine": "Zeile {line}: {reason}", + "skipDetailsOmitted": "{count} weitere übersprungene Zeilen werden nicht angezeigt.", "skipGroupOne": "{count} Eintrag übersprungen: {reason}", "skipGroupFew": "{count} Einträge übersprungen: {reason}", "skipGroupOther": "{count} Einträge übersprungen: {reason}", @@ -4707,7 +4711,7 @@ "mirrored": "Aus einer anderen App gespiegelt, daher keine Dosis ergänzbar: {names}", "unknownColumns": "Spalten ohne Regel im Importer, ungelesen: {names}", "codingsNotRead": "Zeilen mit einem nicht gelesenen Codings-Wert: {count}", - "archivedRows": "Zeilen von einem in der Quell-App archivierten Medikament, trotzdem importiert: {count}", + "archivedRowsPresent": "Als zu archivierten Medikamenten gehörend markierte Zeilen: {count}. Diese Markierung überspringt oder reaktiviert sie nicht; das Endergebnis zeigt, was hinzugefügt wurde.", "verdict": { "honoured": "verwendet", "reported": "gemeldet", diff --git a/messages/en.json b/messages/en.json index 8b5031059..c899eeca1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1942,7 +1942,11 @@ "resultSuccess": "{imported} intakes imported.", "resultPartial": "{imported} imported, {skipped} skipped.", "resultNothing": "Nothing was imported: all {skipped} entries were skipped.", + "resultAlreadyRecorded": "{recorded} intakes are already recorded. Nothing new was added.", "resultEmpty": "That file carried no intakes.", + "showSkipDetails": "Show skipped rows ({count})", + "skipDetailLine": "Line {line}: {reason}", + "skipDetailsOmitted": "{count} more skipped rows are not shown.", "skipGroupOne": "{count} entry skipped: {reason}", "skipGroupFew": "{count} entries skipped: {reason}", "skipGroupOther": "{count} entries skipped: {reason}", @@ -4707,7 +4711,7 @@ "mirrored": "Mirrored from another app, so no dose can be added: {names}", "unknownColumns": "Columns the importer has no rule for, left unread: {names}", "codingsNotRead": "Rows carrying a Codings value that was not read: {count}", - "archivedRows": "Rows from a medication archived in the source app, imported anyway: {count}", + "archivedRowsPresent": "Rows marked as belonging to archived medications: {count}. That flag neither skips nor reactivates them; the final result says what was added.", "verdict": { "honoured": "used", "reported": "reported", diff --git a/messages/es.json b/messages/es.json index ec40c8490..b43859020 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1942,7 +1942,11 @@ "resultSuccess": "{imported} tomas importadas.", "resultPartial": "{imported} importadas, {skipped} omitidas.", "resultNothing": "No se importó nada: se omitieron las {skipped} entradas.", + "resultAlreadyRecorded": "{recorded} tomas ya están registradas. No se añadió nada nuevo.", "resultEmpty": "Ese archivo no contenía ninguna toma.", + "showSkipDetails": "Mostrar filas omitidas ({count})", + "skipDetailLine": "Fila {line}: {reason}", + "skipDetailsOmitted": "No se muestran {count} filas omitidas más.", "skipGroupOne": "{count} entrada omitida: {reason}", "skipGroupFew": "{count} entradas omitidas: {reason}", "skipGroupOther": "{count} entradas omitidas: {reason}", @@ -4707,7 +4711,7 @@ "mirrored": "Copiado de otra aplicación, así que no se puede añadir ninguna dosis: {names}", "unknownColumns": "Columnas sin regla en el importador, no leídas: {names}", "codingsNotRead": "Filas con un valor de Codings no leído: {count}", - "archivedRows": "Filas de un medicamento archivado en la aplicación de origen, importadas igualmente: {count}", + "archivedRowsPresent": "Filas marcadas como pertenecientes a medicamentos archivados: {count}. Esa marca no las omite ni reactiva los medicamentos; el resultado final indica qué se añadió.", "verdict": { "honoured": "usada", "reported": "informada", diff --git a/messages/fr.json b/messages/fr.json index c5e81b5ee..6a53fac75 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1942,7 +1942,11 @@ "resultSuccess": "{imported} prises importées.", "resultPartial": "{imported} importées, {skipped} ignorées.", "resultNothing": "Rien n’a été importé : les {skipped} entrées ont été ignorées.", + "resultAlreadyRecorded": "{recorded} prises sont déjà enregistrées. Rien de nouveau n’a été ajouté.", "resultEmpty": "Ce fichier ne contenait aucune prise.", + "showSkipDetails": "Afficher les lignes ignorées ({count})", + "skipDetailLine": "Ligne {line} : {reason}", + "skipDetailsOmitted": "{count} autres lignes ignorées ne sont pas affichées.", "skipGroupOne": "{count} entrée ignorée : {reason}", "skipGroupFew": "{count} entrées ignorées : {reason}", "skipGroupOther": "{count} entrées ignorées : {reason}", @@ -4707,7 +4711,7 @@ "mirrored": "Copié depuis une autre application, aucune prise ne peut y être ajoutée : {names}", "unknownColumns": "Colonnes sans règle dans l'importeur, non lues : {names}", "codingsNotRead": "Lignes portant une valeur Codings non lue : {count}", - "archivedRows": "Lignes d'un médicament archivé dans l'application source, importées quand même : {count}", + "archivedRowsPresent": "Lignes marquées comme appartenant à des médicaments archivés : {count}. Ce marqueur ne les ignore pas et ne réactive pas les médicaments ; le résultat final indique ce qui a été ajouté.", "verdict": { "honoured": "utilisée", "reported": "signalée", diff --git a/messages/it.json b/messages/it.json index 4e83596dc..773eefecd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1942,7 +1942,11 @@ "resultSuccess": "{imported} assunzioni importate.", "resultPartial": "{imported} importate, {skipped} ignorate.", "resultNothing": "Non è stato importato nulla: tutte le {skipped} voci sono state ignorate.", + "resultAlreadyRecorded": "{recorded} assunzioni sono già registrate. Non è stato aggiunto nulla di nuovo.", "resultEmpty": "Questo file non conteneva assunzioni.", + "showSkipDetails": "Mostra le righe ignorate ({count})", + "skipDetailLine": "Riga {line}: {reason}", + "skipDetailsOmitted": "Altre {count} righe ignorate non sono visualizzate.", "skipGroupOne": "{count} voce ignorata: {reason}", "skipGroupFew": "{count} voci ignorate: {reason}", "skipGroupOther": "{count} voci ignorate: {reason}", @@ -4707,7 +4711,7 @@ "mirrored": "Copiato da un'altra app, quindi non si può aggiungere alcuna dose: {names}", "unknownColumns": "Colonne senza regola nell'importatore, non lette: {names}", "codingsNotRead": "Righe con un valore Codings non letto: {count}", - "archivedRows": "Righe di un farmaco archiviato nell'app di origine, importate comunque: {count}", + "archivedRowsPresent": "Righe contrassegnate come appartenenti a farmaci archiviati: {count}. Il contrassegno non le ignora né riattiva i farmaci; il risultato finale indica cosa è stato aggiunto.", "verdict": { "honoured": "usata", "reported": "segnalata", diff --git a/messages/pl.json b/messages/pl.json index f9df40039..c19f6064e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1942,7 +1942,11 @@ "resultSuccess": "Zaimportowano przyjęcia: {imported}.", "resultPartial": "Zaimportowano {imported}, pominięto {skipped}.", "resultNothing": "Nic nie zaimportowano: pominięto wszystkie wpisy ({skipped}).", + "resultAlreadyRecorded": "Przyjęcia już zapisane: {recorded}. Nie dodano niczego nowego.", "resultEmpty": "Ten plik nie zawierał żadnych przyjęć.", + "showSkipDetails": "Pokaż pominięte wiersze ({count})", + "skipDetailLine": "Wiersz {line}: {reason}", + "skipDetailsOmitted": "Nie pokazano kolejnych pominiętych wierszy: {count}.", "skipGroupOne": "Pominięto {count} wpis: {reason}", "skipGroupFew": "Pominięto {count} wpisy: {reason}", "skipGroupOther": "Pominięto {count} wpisów: {reason}", @@ -4707,7 +4711,7 @@ "mirrored": "Skopiowany z innej aplikacji, więc nie można dodać dawki: {names}", "unknownColumns": "Kolumny bez reguły w importerze, nieodczytane: {names}", "codingsNotRead": "Wiersze z nieodczytaną wartością Codings: {count}", - "archivedRows": "Wiersze leku zarchiwizowanego w aplikacji źródłowej, zaimportowane mimo to: {count}", + "archivedRowsPresent": "Wiersze oznaczone jako należące do zarchiwizowanych leków: {count}. To oznaczenie nie pomija ich ani nie reaktywuje leków; wynik końcowy pokazuje, co dodano.", "verdict": { "honoured": "użyta", "reported": "zgłoszona", diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index 7a7d1bf03..9cf7f4282 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -249,11 +249,13 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => { idempotencyKey: `import-m1-${Date.parse("2026-01-01T07:00:00")}`, scheduledFor: expect.any(String), takenAt: expect.any(String), + sourceLine: 1, }, { idempotencyKey: `import-m1-${Date.parse("2026-01-01T19:00:00")}`, scheduledFor: expect.any(String), takenAt: expect.any(String), + sourceLine: 2, }, ], }, @@ -261,6 +263,8 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => { processed: 0, imported: 0, skippedByReason: {}, + skipDetails: [], + skippedDetailsOmitted: 0, total: 2, }), }), diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 90fa2ac87..42c4f7226 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -175,6 +175,8 @@ export const POST = apiHandler( total: normalized.entries.length, imported: 0, skippedByReason: {}, + skipDetails: [], + skippedDetailsOmitted: 0, touchedDays: [], rollupProcessed: 0, }; diff --git a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts index a5ef639a6..9fdb848b9 100644 --- a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts +++ b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts @@ -154,12 +154,14 @@ describe("POST /api/medications/intake/dose-history-import", () => { scheduledFor: "2024-12-31T21:30:00.000Z", takenAt: "2024-12-31T21:50:00.000Z", idempotencyKey: `import-med-1-${Date.parse("2024-12-31T21:30:00.000Z")}`, + sourceLine: 2, }, { medicationId: "med-1", scheduledFor: "2025-01-01T21:30:00.000Z", takenAt: "2025-01-01T21:35:00.000Z", idempotencyKey: `import-med-1-${Date.parse("2025-01-01T21:30:00.000Z")}`, + sourceLine: 3, // 0.5 against a scheduled 1.0 — a real deviation, so it is kept. doseTaken: "0.5", }, @@ -171,6 +173,8 @@ describe("POST /api/medications/intake/dose-history-import", () => { imported: 0, // Seeded, so the count at the end of the run covers the whole file. skippedByReason: { status_no_dose_information: 1 }, + skipDetails: [{ line: 4, reason: "status_no_dose_information" }], + skippedDetailsOmitted: 0, touchedDays: [], rollupProcessed: 0, }, diff --git a/src/app/api/medications/intake/dose-history-import/route.ts b/src/app/api/medications/intake/dose-history-import/route.ts index 362840a42..731a8e5a3 100644 --- a/src/app/api/medications/intake/dose-history-import/route.ts +++ b/src/app/api/medications/intake/dose-history-import/route.ts @@ -183,6 +183,8 @@ export const POST = apiHandler(async (request: NextRequest) => { total: plan.entries.length, imported: 0, skippedByReason: plan.skippedByReason, + skipDetails: plan.skipDetails, + skippedDetailsOmitted: plan.skippedDetailsOmitted, touchedDays: [], rollupProcessed: 0, }; diff --git a/src/components/medications/__tests__/intake-import-result.test.tsx b/src/components/medications/__tests__/intake-import-result.test.tsx index 640a7369c..bcf9f25bb 100644 --- a/src/components/medications/__tests__/intake-import-result.test.tsx +++ b/src/components/medications/__tests__/intake-import-result.test.tsx @@ -73,6 +73,55 @@ describe(" — an import that wrote nothing", () => { expect(html).toContain("another entry in the same file"); expect(countListItems(html)).toBe(2); }); + + it("calls an all-existing replay already recorded instead of a failed import", () => { + const html = render({ + kind: "outcome", + imported: 0, + skipped: 3386, + skipReasons: [{ reason: "already_recorded", count: 3386 }], + }); + expect(html).toContain("3386 intakes are already recorded"); + expect(html).toContain("Nothing new was added"); + expect(html).not.toContain("Nothing was imported"); + }); + + it("uses the already-recorded headline when preflight refusals also exist", () => { + const html = render({ + kind: "outcome", + imported: 0, + skipped: 3395, + skipReasons: [ + { reason: "already_recorded", count: 3386 }, + { reason: "status_no_dose_information", count: 8 }, + { reason: "duplicate_in_file", count: 1 }, + ], + }); + expect(html).toContain("3386 intakes are already recorded"); + expect(html).toContain("8 entries skipped"); + expect(html).toContain("1 entry skipped"); + expect(html).not.toContain("Nothing was imported"); + }); + + it("shows bounded line-and-reason detail without health data", () => { + const html = render({ + kind: "outcome", + imported: 0, + skipped: 3, + skipReasons: [{ reason: "already_recorded", count: 3 }], + skipDetails: [ + { line: 2, reason: "already_recorded" }, + { line: 9, reason: "already_recorded" }, + ], + skippedDetailsOmitted: 1, + }); + expect(html).toContain('data-testid="intake-import-skip-details"'); + expect(html).toContain("Line 2"); + expect(html).toContain("Line 9"); + expect(html).toContain("1 more skipped rows"); + expect(html).not.toContain("medicationId"); + expect(html).not.toContain("takenAt"); + }); }); describe(" — the other outcomes", () => { diff --git a/src/components/medications/intake-import-result.tsx b/src/components/medications/intake-import-result.tsx index d0945bc04..60ccca317 100644 --- a/src/components/medications/intake-import-result.tsx +++ b/src/components/medications/intake-import-result.tsx @@ -43,6 +43,8 @@ export type IntakeImportResultState = imported: number; skipped: number; skipReasons: IntakeImportSkipGroup[]; + skipDetails?: Array<{ line: number; reason: string }>; + skippedDetailsOmitted?: number; }; /** @@ -119,6 +121,7 @@ export function IntakeImportResultView({ result: IntakeImportResultState; }) { const { t } = useTranslations(); + const labelFor = useSkipReasonLabel(); if (result.kind === "notice") { return ( @@ -136,10 +139,20 @@ export function IntakeImportResultView({ written: result.imported, skipped: result.skipped, }); + const alreadyRecorded = result.skipReasons.find( + (group) => group.reason === "already_recorded", + ); + const replayWithNoWrites = + result.imported === 0 && (alreadyRecorded?.count ?? 0) > 0; const message = (() => { if (outcome === "empty") return t("medications.import.resultEmpty"); if (outcome === "failed") { + if (replayWithNoWrites) { + return t("medications.import.resultAlreadyRecorded", { + recorded: alreadyRecorded?.count ?? 0, + }); + } return t("medications.import.resultNothing", { skipped: result.skipped }); } if (outcome === "partial") { @@ -159,6 +172,35 @@ export function IntakeImportResultView({ testId="intake-import-outcome" /> + {(result.skipDetails?.length ?? 0) > 0 && ( +
+ + {t("medications.import.showSkipDetails", { + count: result.skipDetails?.length ?? 0, + })} + +
    + {result.skipDetails?.map((detail) => ( +
  • + {t("medications.import.skipDetailLine", { + line: detail.line, + reason: labelFor(detail.reason), + })} +
  • + ))} +
+ {(result.skippedDetailsOmitted ?? 0) > 0 && ( +

+ {t("medications.import.skipDetailsOmitted", { + count: result.skippedDetailsOmitted ?? 0, + })} +

+ )} +
+ )} ); } diff --git a/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx b/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx index b35ca4fd8..a7ed1eb91 100644 --- a/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx +++ b/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx @@ -26,7 +26,10 @@ import { AUTO_EXPORT_COLUMN_RULINGS } from "@/lib/medications/import/auto-export import { IntakeImportSkipGroups } from "@/components/medications/intake-import-result"; import { DoseHistoryColumnRulings } from "../dose-history-columns"; -import { DoseHistoryImportCard } from "../dose-history-import-card"; +import { + DoseHistoryImportCard, + shouldShowDoseHistoryVerdict, +} from "../dose-history-import-card"; import { DoseHistoryVerdictView, type DoseHistoryFileVerdict, @@ -84,6 +87,21 @@ describe("", () => { expect(html).not.toContain('data-outcome="success"'); expect(html).not.toContain("text-success"); }); + + it("replaces preflight counts with the final result instead of repeating skips", () => { + expect(shouldShowDoseHistoryVerdict(null)).toBe(true); + expect( + shouldShowDoseHistoryVerdict({ + kind: "outcome", + imported: 0, + skipped: 9, + skipReasons: [ + { reason: "status_no_dose_information", count: 8 }, + { reason: "duplicate_in_file", count: 1 }, + ], + }), + ).toBe(false); + }); }); describe("", () => { @@ -115,13 +133,15 @@ describe("", () => { expect(html).not.toContain("intake-import-outcome"); }); - it("reports the archived rows as imported rather than as a reason to drop them", () => { + it("reports archived rows as file content without claiming they were written", () => { const html = render( , ); expect(html).toContain('data-testid="dose-history-archived"'); expect(html).toContain("1462"); - expect(html).toContain("imported anyway"); + expect(html).toContain("neither skips nor reactivates"); + expect(html).toContain("final result"); + expect(html).not.toContain("imported anyway"); // Archived rows are reported, never refused. expect(html).not.toContain("dose-history-unmatched"); }); diff --git a/src/components/settings/import-panel/dose-history-import-card.tsx b/src/components/settings/import-panel/dose-history-import-card.tsx index 058d16467..9a07dd061 100644 --- a/src/components/settings/import-panel/dose-history-import-card.tsx +++ b/src/components/settings/import-panel/dose-history-import-card.tsx @@ -51,6 +51,13 @@ interface DoseHistoryKickoff { file: DoseHistoryFileVerdict; } +/** A final result supersedes the preflight counts so skips are stated once. */ +export function shouldShowDoseHistoryVerdict( + outcome: IntakeImportResultState | null, +): boolean { + return outcome === null; +} + /** * Plain language per machine-readable file-level refusal. Literal `t()` calls so * the i18n call-site guard sees every one. @@ -182,6 +189,8 @@ export function DoseHistoryImportCard() { imported: result.imported, skipped: result.skipped, skipReasons: result.skipReasons, + skipDetails: result.skipDetails, + skippedDetailsOmitted: result.skippedDetailsOmitted, }); void invalidateMedicationReads(queryClient); } catch (err) { @@ -243,7 +252,7 @@ export function DoseHistoryImportCard() {
- {verdict && ( + {verdict && shouldShowDoseHistoryVerdict(outcome) && ( )} {outcome && } diff --git a/src/components/settings/import-panel/dose-history-verdict.tsx b/src/components/settings/import-panel/dose-history-verdict.tsx index bab72db84..e581769fe 100644 --- a/src/components/settings/import-panel/dose-history-verdict.tsx +++ b/src/components/settings/import-panel/dose-history-verdict.tsx @@ -103,9 +103,12 @@ export function DoseHistoryVerdictView({ className="text-muted-foreground" data-testid="dose-history-archived" > - {t("settings.sections.export.import.doseHistory.archivedRows", { - count: verdict.fromArchivedMedications, - })} + {t( + "settings.sections.export.import.doseHistory.archivedRowsPresent", + { + count: verdict.fromArchivedMedications, + }, + )}

)}
diff --git a/src/lib/jobs/__tests__/medication-intake-import.test.ts b/src/lib/jobs/__tests__/medication-intake-import.test.ts index 205ecd0ba..fb9d28035 100644 --- a/src/lib/jobs/__tests__/medication-intake-import.test.ts +++ b/src/lib/jobs/__tests__/medication-intake-import.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { + MEDICATION_IMPORT_SKIP_DETAIL_LIMIT, MEDICATION_INTAKE_IMPORT_CHUNK_SIZE, MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE, _setMedicationIntakeImportPrismaForTests, advanceMedicationImportProgress, + appendMedicationImportSkipDetails, groupMedicationImportSkips, medicationImportChunk, processMedicationIntakeImportJob, @@ -245,4 +247,26 @@ describe("medication import skip reporting (#650)", () => { { reason: "already_recorded", count: 5 }, ]); }); + + it("bounds retained row detail and counts the remainder", () => { + const details = Array.from( + { length: MEDICATION_IMPORT_SKIP_DETAIL_LIMIT + 37 }, + (_, index) => ({ + line: index + 1, + reason: "already_recorded" as const, + }), + ); + const accumulated = appendMedicationImportSkipDetails( + { skipDetails: [], skippedDetailsOmitted: 0 }, + details, + ); + + expect(accumulated.skipDetails).toHaveLength( + MEDICATION_IMPORT_SKIP_DETAIL_LIMIT, + ); + expect(accumulated.skippedDetailsOmitted).toBe(37); + expect(Buffer.byteLength(JSON.stringify(accumulated), "utf8")).toBeLessThan( + 16 * 1024, + ); + }); }); diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index 2bf370f41..2fa068568 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -47,6 +47,12 @@ export interface MedicationImportEntry { * absence is how `MedicationIntakeEvent.doseTaken` already says so. */ doseTaken?: string; + /** + * 1-based row in the submitted file. This is the only source-row identity + * persisted for result details: never carry medication, dose, timestamp or + * raw file content into the status response. + */ + sourceLine?: number; } export interface MedicationImportPayload { @@ -105,11 +111,28 @@ export type MedicationImportSkipCounts = Partial< Record >; +/** Maximum source rows returned through the background-job status response. */ +export const MEDICATION_IMPORT_SKIP_DETAIL_LIMIT = 200; +export const MEDICATION_IMPORT_MAX_SOURCE_LINE = 30_001; + +/** + * Safe per-row detail. The allowlisted reason and ordinal are enough to find a + * row in the person's own file without persisting health data from that row. + */ +export interface MedicationImportSkipDetail { + line: number; + reason: MedicationImportSkipReason; +} + export interface MedicationImportProgress { processed: number; total: number; imported: number; skippedByReason: MedicationImportSkipCounts; + /** First bounded source-row details; absent on legacy jobs. */ + skipDetails?: MedicationImportSkipDetail[]; + /** Details beyond the hard cap, counted without retaining their contents. */ + skippedDetailsOmitted?: number; touchedDays: string[]; rollupProcessed: number; } @@ -128,6 +151,36 @@ export interface MedicationImportResult { * sentence repeated per row. Empty when nothing was skipped. */ skipReasons: MedicationImportSkipGroup[]; + /** Bounded, redacted source-row details. */ + skipDetails?: MedicationImportSkipDetail[]; + /** Number of additional skipped rows not retained in `skipDetails`. */ + skippedDetailsOmitted?: number; +} + +export interface MedicationImportSkipDetailAccumulator { + skipDetails: MedicationImportSkipDetail[]; + skippedDetailsOmitted: number; +} + +/** + * Append safe row details without ever letting the persisted/status payload + * grow with the input file. Callers must already have reduced rows to the + * allowlisted ordinal + reason shape. + */ +export function appendMedicationImportSkipDetails( + current: MedicationImportSkipDetailAccumulator, + incoming: readonly MedicationImportSkipDetail[], +): MedicationImportSkipDetailAccumulator { + const capacity = Math.max( + 0, + MEDICATION_IMPORT_SKIP_DETAIL_LIMIT - current.skipDetails.length, + ); + const retained = incoming.slice(0, capacity); + return { + skipDetails: [...current.skipDetails, ...retained], + skippedDetailsOmitted: + current.skippedDetailsOmitted + incoming.length - retained.length, + }; } /** Collapse the running counts into the wire shape, in a stable order. */ @@ -161,6 +214,7 @@ export interface MedicationImportChunkResult { processed: number; imported: number; skippedByReason: MedicationImportSkipCounts; + skipDetails?: MedicationImportSkipDetail[]; touchedDays: string[]; } @@ -212,11 +266,29 @@ export function advanceMedicationImportProgress( skippedByReason[reason] = (skippedByReason[reason] ?? 0) + added; } + const detailAccumulator = appendMedicationImportSkipDetails( + { + skipDetails: previous.skipDetails ?? [], + skippedDetailsOmitted: previous.skippedDetailsOmitted ?? 0, + }, + chunk.skipDetails ?? [], + ); + const includeDetails = + previous.skipDetails !== undefined || + previous.skippedDetailsOmitted !== undefined || + (chunk.skipDetails?.length ?? 0) > 0; + return { processed, total: previous.total, imported: previous.imported + Math.max(0, Math.trunc(chunk.imported)), skippedByReason, + ...(includeDetails + ? { + skipDetails: detailAccumulator.skipDetails, + skippedDetailsOmitted: detailAccumulator.skippedDetailsOmitted, + } + : {}), rollupProcessed: previous.rollupProcessed, touchedDays, }; @@ -278,6 +350,15 @@ function parsePayload(value: Prisma.JsonValue): MedicationImportPayload { if (entry.doseTaken !== undefined && typeof entry.doseTaken !== "string") { throw new Error("Medication intake import entry is malformed"); } + if ( + entry.sourceLine !== undefined && + (typeof entry.sourceLine !== "number" || + !Number.isInteger(entry.sourceLine) || + entry.sourceLine < 1 || + entry.sourceLine > MEDICATION_IMPORT_MAX_SOURCE_LINE) + ) { + throw new Error("Medication intake import entry is malformed"); + } return { scheduledFor: rawScheduledFor, takenAt: takenAt as string | null, @@ -286,6 +367,9 @@ function parsePayload(value: Prisma.JsonValue): MedicationImportPayload { ? {} : { medicationId: entry.medicationId }), ...(entry.doseTaken === undefined ? {} : { doseTaken: entry.doseTaken }), + ...(entry.sourceLine === undefined + ? {} + : { sourceLine: entry.sourceLine }), }; }); return { entries }; @@ -335,6 +419,15 @@ function parseProgress( : null; const rollupProcessed = value.rollupProcessed === undefined ? 0 : Number(value.rollupProcessed); + const rawSkipDetails = value.skipDetails; + const skipDetails = + rawSkipDetails === undefined + ? undefined + : parseMedicationImportSkipDetails(rawSkipDetails); + const skippedDetailsOmitted = + value.skippedDetailsOmitted === undefined + ? undefined + : Number(value.skippedDetailsOmitted); if ( !Number.isInteger(processed) || processed < 0 || @@ -345,7 +438,10 @@ function parseProgress( touchedDays === null || !Number.isInteger(rollupProcessed) || rollupProcessed < 0 || - rollupProcessed > (touchedDays?.length ?? 0) + rollupProcessed > (touchedDays?.length ?? 0) || + (rawSkipDetails !== undefined && skipDetails === null) || + (skippedDetailsOmitted !== undefined && + (!Number.isInteger(skippedDetailsOmitted) || skippedDetailsOmitted < 0)) ) { throw new Error("Medication intake import progress is malformed"); } @@ -354,11 +450,49 @@ function parseProgress( total, imported, skippedByReason, + ...(skipDetails === undefined || skipDetails === null + ? {} + : { skipDetails }), + ...(skippedDetailsOmitted === undefined ? {} : { skippedDetailsOmitted }), touchedDays, rollupProcessed, }; } +/** Strictly parse only the two safe fields permitted in a row detail. */ +export function parseMedicationImportSkipDetails( + value: Prisma.JsonValue, +): MedicationImportSkipDetail[] | null { + if ( + !Array.isArray(value) || + value.length > MEDICATION_IMPORT_SKIP_DETAIL_LIMIT + ) { + return null; + } + const details: MedicationImportSkipDetail[] = []; + for (const detail of value) { + if ( + typeof detail !== "object" || + detail === null || + Array.isArray(detail) || + !Number.isInteger(detail.line) || + Number(detail.line) < 1 || + Number(detail.line) > MEDICATION_IMPORT_MAX_SOURCE_LINE || + typeof detail.reason !== "string" || + !(MEDICATION_IMPORT_SKIP_REASONS as readonly string[]).includes( + detail.reason, + ) + ) { + return null; + } + details.push({ + line: Number(detail.line), + reason: detail.reason as MedicationImportSkipReason, + }); + } + return details; +} + interface ProcessChunkOutcome { terminal: boolean; finalized: boolean; @@ -502,6 +636,12 @@ async function processNextChunk( imported: progress.imported, skipped: totalMedicationImportSkips(progress.skippedByReason), skipReasons: groupMedicationImportSkips(progress.skippedByReason), + ...(progress.skipDetails === undefined + ? {} + : { skipDetails: progress.skipDetails }), + ...(progress.skippedDetailsOmitted === undefined + ? {} + : { skippedDetailsOmitted: progress.skippedDetailsOmitted }), }; const completedAt = new Date(); await tx.auditLog.create({ @@ -541,11 +681,21 @@ async function processNextChunk( payload.entries, progress.processed, ); - const uniqueEntries = [ - ...new Map( - entries.map((entry) => [entry.idempotencyKey, entry]), - ).values(), - ]; + const uniqueByKey = new Map(); + const duplicateDetails: MedicationImportSkipDetail[] = []; + for (const entry of entries) { + if (uniqueByKey.has(entry.idempotencyKey)) { + if (entry.sourceLine !== undefined) { + duplicateDetails.push({ + line: entry.sourceLine, + reason: "duplicate_in_file", + }); + } + continue; + } + uniqueByKey.set(entry.idempotencyKey, entry); + } + const uniqueEntries = [...uniqueByKey.values()]; // An entry names its own medication when the job came from an export file // covering a whole regimen; the per-medication route's jobs name it on the // job row instead. One of the two must be there, and a payload with @@ -589,8 +739,22 @@ async function processNextChunk( medicationId: true, takenAt: true, scheduledFor: true, + idempotencyKey: true, }, }); + const createdKeys = new Set(created.map((event) => event.idempotencyKey)); + const alreadyRecordedDetails: MedicationImportSkipDetail[] = []; + for (const entry of uniqueEntries) { + if ( + !createdKeys.has(entry.idempotencyKey) && + entry.sourceLine !== undefined + ) { + alreadyRecordedDetails.push({ + line: entry.sourceLine, + reason: "already_recorded", + }); + } + } // Inventory is per medication and a skip consumes nothing, so the created // rows are grouped and the skips left out. `consumeImportedIntakesBatch` @@ -636,6 +800,7 @@ async function processNextChunk( duplicate_in_file: entries.length - uniqueEntries.length, already_recorded: uniqueEntries.length - created.length, }, + skipDetails: [...duplicateDetails, ...alreadyRecordedDetails], touchedDays, }); const heartbeatAt = new Date(); diff --git a/src/lib/medications/__tests__/intake-import-job-status.test.ts b/src/lib/medications/__tests__/intake-import-job-status.test.ts new file mode 100644 index 000000000..7a844b3f7 --- /dev/null +++ b/src/lib/medications/__tests__/intake-import-job-status.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { MEDICATION_IMPORT_SKIP_DETAIL_LIMIT } from "@/lib/jobs/medication-intake-import"; +import { projectMedicationImportResult } from "../intake-import-job-status"; + +describe("projectMedicationImportResult", () => { + it("carries bounded line-and-reason detail and nothing else", () => { + const projected = projectMedicationImportResult({ + imported: 0, + skipped: 2, + skipReasons: [{ reason: "already_recorded", count: 2 }], + skipDetails: [ + { + line: 7, + reason: "already_recorded", + medicationName: "SECRET MEDICATION", + takenAt: "2026-07-01T08:00:00.000Z", + credential: "token-secret", + }, + ], + skippedDetailsOmitted: 1, + rawFile: "private health export", + } as never); + + expect(projected).toEqual({ + imported: 0, + skipped: 2, + skipReasons: [{ reason: "already_recorded", count: 2 }], + skipDetails: [{ line: 7, reason: "already_recorded" }], + skippedDetailsOmitted: 1, + }); + const wire = JSON.stringify(projected); + expect(wire).not.toContain("SECRET MEDICATION"); + expect(wire).not.toContain("token-secret"); + expect(wire).not.toContain("private health export"); + expect(wire).not.toContain("takenAt"); + }); + + it.each([ + { + label: "unknown reason", + result: { + imported: 0, + skipped: 1, + skipReasons: [{ reason: "credential=secret", count: 1 }], + }, + }, + { + label: "health data in the reason count shape", + result: { + imported: 0, + skipped: 1, + skipReasons: [ + { reason: "already_recorded", count: 1, dose: "Mounjaro 15mg" }, + ], + skipDetails: [{ line: 1, reason: "Mounjaro 15mg" }], + }, + }, + { + label: "more details than the cap", + result: { + imported: 0, + skipped: MEDICATION_IMPORT_SKIP_DETAIL_LIMIT + 1, + skipReasons: [ + { + reason: "already_recorded", + count: MEDICATION_IMPORT_SKIP_DETAIL_LIMIT + 1, + }, + ], + skipDetails: Array.from( + { length: MEDICATION_IMPORT_SKIP_DETAIL_LIMIT + 1 }, + (_, index) => ({ line: index + 1, reason: "already_recorded" }), + ), + }, + }, + ])( + "rejects a malformed $label result instead of leaking it", + ({ result }) => { + expect(projectMedicationImportResult(result as never)).toBeNull(); + }, + ); +}); diff --git a/src/lib/medications/__tests__/intake-import-poll.test.ts b/src/lib/medications/__tests__/intake-import-poll.test.ts index 4826c29ce..3088ec569 100644 --- a/src/lib/medications/__tests__/intake-import-poll.test.ts +++ b/src/lib/medications/__tests__/intake-import-poll.test.ts @@ -14,6 +14,8 @@ describe("waitForMedicationIntakeImport", () => { imported: 3, skipped: 1, skipReasons: [{ reason: "already_recorded", count: 1 }], + skipDetails: [{ line: 7, reason: "already_recorded" }], + skippedDetailsOmitted: 0, }, }); const wait = vi.fn().mockResolvedValue(undefined); @@ -27,6 +29,8 @@ describe("waitForMedicationIntakeImport", () => { imported: 3, skipped: 1, skipReasons: [{ reason: "already_recorded", count: 1 }], + skipDetails: [{ line: 7, reason: "already_recorded" }], + skippedDetailsOmitted: 0, }); expect(readStatus).toHaveBeenCalledTimes(3); expect(wait).toHaveBeenCalledTimes(2); diff --git a/src/lib/medications/import/__tests__/auto-export-plan.test.ts b/src/lib/medications/import/__tests__/auto-export-plan.test.ts index 15db73415..de10b9eb3 100644 --- a/src/lib/medications/import/__tests__/auto-export-plan.test.ts +++ b/src/lib/medications/import/__tests__/auto-export-plan.test.ts @@ -124,6 +124,7 @@ describe("planAutoExportImport — the reported export", () => { scheduledFor: "2023-02-15T21:30:00.000Z", takenAt: "2023-02-15T22:08:00.000Z", idempotencyKey: `import-id-Med_13-${Date.parse("2023-02-15T21:30:00.000Z")}`, + sourceLine: 2, }); }); }); @@ -212,7 +213,27 @@ describe("planAutoExportImport — matching", () => { scheduledFor: "2025-01-01T08:00:00.000Z", takenAt: null, idempotencyKey: `import-id-Ramipril-${Date.parse("2025-01-01T08:00:00.000Z")}`, + sourceLine: 2, }, ]); }); + + it("keeps only safe line-and-reason detail for every refusal", () => { + const plan = planRows( + [ + "2025-01-01 08:00:00 +0000,2025-01-01 08:00:00 +0000,Ramipril,,1.0,1.0,count,Taken,No,", + "2025-01-01 08:30:00 +0000,2025-01-01 08:00:00 +0000,Ramipril,,1.0,1.0,count,Taken,No,", + "2025-01-02 08:00:00 +0000,,Unknown medicine,,1.0,1.0,count,Taken,No,", + ], + [medication("Ramipril")], + ); + + expect(plan.skipDetails).toEqual([ + { line: 3, reason: "duplicate_in_file" }, + { line: 4, reason: "medication_not_found" }, + ]); + expect(plan.skippedDetailsOmitted).toBe(0); + expect(JSON.stringify(plan.skipDetails)).not.toContain("Ramipril"); + expect(JSON.stringify(plan.skipDetails)).not.toContain("2025-01"); + }); }); diff --git a/src/lib/medications/import/auto-export-plan.ts b/src/lib/medications/import/auto-export-plan.ts index a2c3f41ea..8dd37de33 100644 --- a/src/lib/medications/import/auto-export-plan.ts +++ b/src/lib/medications/import/auto-export-plan.ts @@ -11,8 +11,10 @@ import type { MedicationImportEntry, MedicationImportSkipCounts, + MedicationImportSkipDetail, MedicationImportSkipReason, } from "@/lib/jobs/medication-intake-import"; +import { appendMedicationImportSkipDetails } from "@/lib/jobs/medication-intake-import"; import { medicationImportIdempotencyKey } from "@/lib/medications/intake-import-payload"; import { normaliseMedicationName } from "./auto-export-format"; @@ -43,6 +45,9 @@ export interface AutoExportPlan { * that reached the database. */ skippedByReason: MedicationImportSkipCounts; + /** Bounded line + reason detail, safe to persist through the job result. */ + skipDetails: MedicationImportSkipDetail[]; + skippedDetailsOmitted: number; /** * Names the file used that match nothing on the record, deduplicated and in * first-seen order. Surfaced verbatim so the person can rename or add the @@ -100,7 +105,23 @@ export function planAutoExportImport( medications: readonly MedicationMatchCandidate[], ): AutoExportPlan { const skippedByReason: MedicationImportSkipCounts = {}; - for (const refusal of parsed.refusals) bump(skippedByReason, refusal.reason); + let skipDetails: MedicationImportSkipDetail[] = []; + let skippedDetailsOmitted = 0; + const recordSkip = ( + line: number, + reason: MedicationImportSkipReason, + ): void => { + bump(skippedByReason, reason); + const next = appendMedicationImportSkipDetails( + { skipDetails, skippedDetailsOmitted }, + [{ line, reason }], + ); + skipDetails = next.skipDetails; + skippedDetailsOmitted = next.skippedDetailsOmitted; + }; + for (const refusal of parsed.refusals) { + recordSkip(refusal.line, refusal.reason); + } const byName = new Map(); for (const medication of medications) { @@ -154,7 +175,7 @@ export function planAutoExportImport( for (const dose of parsed.doses) { const match = resolve(dose); if (!match.ok) { - bump(skippedByReason, match.reason); + recordSkip(dose.line, match.reason); const label = dose.medicationName; if (!namedSeen[match.reason].has(label)) { namedSeen[match.reason].add(label); @@ -172,7 +193,7 @@ export function planAutoExportImport( // it exactly, instead of leaving the second row to surface later as though // the record had already held that dose. if (seenKeys.has(idempotencyKey)) { - bump(skippedByReason, "duplicate_in_file"); + recordSkip(dose.line, "duplicate_in_file"); continue; } seenKeys.add(idempotencyKey); @@ -182,6 +203,7 @@ export function planAutoExportImport( scheduledFor: dose.scheduledFor.toISOString(), takenAt: dose.takenAt === null ? null : dose.takenAt.toISOString(), idempotencyKey, + sourceLine: dose.line, ...(dose.doseTaken === null ? {} : { doseTaken: dose.doseTaken }), }); } @@ -189,6 +211,8 @@ export function planAutoExportImport( return { entries, skippedByReason, + skipDetails, + skippedDetailsOmitted, unmatchedMedications: named.medication_not_found, ambiguousMedications: named.medication_ambiguous, mirroredMedications: named.medication_is_mirrored, diff --git a/src/lib/medications/intake-import-job-status.ts b/src/lib/medications/intake-import-job-status.ts index 54e01d738..7cebc289f 100644 --- a/src/lib/medications/intake-import-job-status.ts +++ b/src/lib/medications/intake-import-job-status.ts @@ -8,18 +8,103 @@ */ import { prisma } from "@/lib/db"; import type { Prisma } from "@/generated/prisma/client"; +import { + MEDICATION_IMPORT_SKIP_REASONS, + parseMedicationImportSkipDetails, + type MedicationImportResult, +} from "@/lib/jobs/medication-intake-import"; export interface MedicationIntakeImportJobStatus { jobId: string; status: string; progress: Prisma.JsonValue; - result: Prisma.JsonValue | null; + result: MedicationImportResult | null; failureReason: string | null; createdAt: string; startedAt: string | null; completedAt: string | null; } +/** + * Project the persisted result through a strict allowlist before it reaches a + * polling response. A compromised or legacy JSON blob cannot smuggle raw file + * data, credentials, medication names, timestamps or arbitrary error prose. + */ +export function projectMedicationImportResult( + value: Prisma.JsonValue | null, +): MedicationImportResult | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const imported = Number(value.imported); + const skipped = Number(value.skipped); + if ( + !Number.isInteger(imported) || + imported < 0 || + !Number.isInteger(skipped) || + skipped < 0 || + !Array.isArray(value.skipReasons) + ) { + return null; + } + const skipReasons = value.skipReasons.flatMap((group) => { + if ( + typeof group !== "object" || + group === null || + Array.isArray(group) || + typeof group.reason !== "string" || + !(MEDICATION_IMPORT_SKIP_REASONS as readonly string[]).includes( + group.reason, + ) || + !Number.isInteger(group.count) || + Number(group.count) < 1 + ) { + return []; + } + return [ + { + reason: group.reason as (typeof MEDICATION_IMPORT_SKIP_REASONS)[number], + count: Number(group.count), + }, + ]; + }); + if ( + skipReasons.length !== value.skipReasons.length || + skipReasons.reduce((sum, group) => sum + group.count, 0) !== skipped + ) { + return null; + } + + const rawDetails = value.skipDetails; + const skipDetails = + rawDetails === undefined + ? undefined + : parseMedicationImportSkipDetails(rawDetails); + const skippedDetailsOmitted = + value.skippedDetailsOmitted === undefined + ? undefined + : Number(value.skippedDetailsOmitted); + if ( + (rawDetails !== undefined && skipDetails === null) || + (skippedDetailsOmitted !== undefined && + (!Number.isInteger(skippedDetailsOmitted) || + skippedDetailsOmitted < 0)) || + (skipDetails?.length ?? 0) + (skippedDetailsOmitted ?? 0) > skipped + ) { + return null; + } + + return { + imported, + skipped, + skipReasons, + ...(skipDetails === undefined || skipDetails === null + ? {} + : { skipDetails }), + ...(skippedDetailsOmitted === undefined ? {} : { skippedDetailsOmitted }), + }; +} + /** * Read one job the caller owns, or `null`. * @@ -46,7 +131,7 @@ export async function readMedicationIntakeImportJob( jobId: row.id, status: row.status, progress: row.progress, - result: row.result, + result: projectMedicationImportResult(row.result), failureReason: row.failureReason, createdAt: row.createdAt.toISOString(), startedAt: row.startedAt?.toISOString() ?? null, diff --git a/src/lib/medications/intake-import-payload.ts b/src/lib/medications/intake-import-payload.ts index a1d13d85e..da8ace19b 100644 --- a/src/lib/medications/intake-import-payload.ts +++ b/src/lib/medications/intake-import-payload.ts @@ -16,6 +16,7 @@ export interface MedicationImportQueueEntry { scheduledFor: string; takenAt: string; idempotencyKey: string; + sourceLine: number; } /** @@ -58,7 +59,7 @@ export function buildMedicationImportEntries( medicationId: string, rows: readonly MedicationImportRow[], ): MedicationImportQueueEntry[] { - return rows.map((row) => { + return rows.map((row, index) => { const takenAt = new Date(`${row.datum}T${row.uhrzeit}`); return { // This body carries one time per row and nothing to say which slot it @@ -68,6 +69,7 @@ export function buildMedicationImportEntries( scheduledFor: takenAt.toISOString(), takenAt: takenAt.toISOString(), idempotencyKey: medicationImportIdempotencyKey(medicationId, takenAt), + sourceLine: index + 1, }; }); } diff --git a/src/lib/medications/intake-import-poll.ts b/src/lib/medications/intake-import-poll.ts index 6e52f3e67..9c9af0050 100644 --- a/src/lib/medications/intake-import-poll.ts +++ b/src/lib/medications/intake-import-poll.ts @@ -5,6 +5,9 @@ export interface MedicationIntakeImportResult { skipped: number; /** One entry per reason with its count — never the same line per row. */ skipReasons: Array<{ reason: string; count: number }>; + /** Bounded source row ordinals and allowlisted reasons only. */ + skipDetails?: Array<{ line: number; reason: string }>; + skippedDetailsOmitted?: number; } interface MedicationIntakeImportStatus { diff --git a/tests/integration/medication-dose-history-import.test.ts b/tests/integration/medication-dose-history-import.test.ts index 740c82890..4711edbfc 100644 --- a/tests/integration/medication-dose-history-import.test.ts +++ b/tests/integration/medication-dose-history-import.test.ts @@ -108,6 +108,8 @@ async function enqueue(userId: string) { total: plan.entries.length, imported: 0, skippedByReason: plan.skippedByReason, + skipDetails: plan.skipDetails, + skippedDetailsOmitted: plan.skippedDetailsOmitted, touchedDays: [], rollupProcessed: 0, }), @@ -120,7 +122,7 @@ describe("dose-history import — the reported export end to end", () => { it("lands every dose the file records a decision for", async () => { const prisma = getPrismaClient(); const user = await seedRegimen(); - const { jobId } = await enqueue(user.id); + const { jobId, plan } = await enqueue(user.id); const result = await processMedicationIntakeImportJob(jobId); @@ -130,6 +132,8 @@ describe("dose-history import — the reported export end to end", () => { // The eight `Not Interacted` rows: the file states no decision about those // doses, so there is nothing honest to write, and the count says which. skipReasons: [{ reason: "status_no_dose_information", count: 8 }], + skipDetails: plan.skipDetails, + skippedDetailsOmitted: 0, }); // Every row of the file is accounted for by one of the two numbers. expect(result!.imported + result!.skipped).toBe(3395); @@ -218,7 +222,7 @@ describe("dose-history import — the reported export end to end", () => { const second = await enqueue(user.id); const result = await processMedicationIntakeImportJob(second.jobId); - expect(result).toEqual({ + expect(result).toMatchObject({ imported: 0, skipped: 3395, skipReasons: [ @@ -226,6 +230,13 @@ describe("dose-history import — the reported export end to end", () => { { reason: "status_no_dose_information", count: 8 }, ], }); + expect(result?.skipDetails).toHaveLength(200); + expect(result?.skipDetails?.slice(0, 8)).toEqual(second.plan.skipDetails); + expect(result?.skippedDetailsOmitted).toBe(3195); + const detailWire = JSON.stringify(result?.skipDetails); + expect(detailWire).not.toContain("Med_"); + expect(detailWire).not.toContain("scheduledFor"); + expect(detailWire).not.toContain("takenAt"); await expect( prisma.medicationIntakeEvent.count({ where: { userId: user.id } }), ).resolves.toBe(3387);