fix(medications): make import replay results honest

This commit is contained in:
Marc-André Bombeck committed 2026-07-29 16:11:45 +02:00
1 parent 5b695f43ea
commit 271f0ed28a
25 files changed
+607 -27

No files matched your search

+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
@@ -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,
}),
}),
@@ -175,6 +175,8 @@ export const POST = apiHandler(
total: normalized.entries.length,
imported: 0,
skippedByReason: {},
skipDetails: [],
skippedDetailsOmitted: 0,
touchedDays: [],
rollupProcessed: 0,
};
@@ -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,
},
@@ -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,
};
@@ -73,6 +73,55 @@ describe("<IntakeImportResultView> — 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("<IntakeImportResultView> — the other outcomes", () => {
@@ -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"
/>
<IntakeImportSkipGroups groups={result.skipReasons} />
{(result.skipDetails?.length ?? 0) > 0 && (
<details
data-testid="intake-import-skip-details"
className="text-muted-foreground text-xs"
>
<summary className="cursor-pointer">
{t("medications.import.showSkipDetails", {
count: result.skipDetails?.length ?? 0,
})}
</summary>
<ul className="mt-1 max-h-48 space-y-0.5 overflow-y-auto pl-4">
{result.skipDetails?.map((detail) => (
<li key={`${detail.line}:${detail.reason}`}>
{t("medications.import.skipDetailLine", {
line: detail.line,
reason: labelFor(detail.reason),
})}
</li>
))}
</ul>
{(result.skippedDetailsOmitted ?? 0) > 0 && (
<p className="mt-1">
{t("medications.import.skipDetailsOmitted", {
count: result.skippedDetailsOmitted ?? 0,
})}
</p>
)}
</details>
)}
</div>
);
}
@@ -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("<DoseHistoryImportCard>", () => {
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("<DoseHistoryVerdictView>", () => {
@@ -115,13 +133,15 @@ describe("<DoseHistoryVerdictView>", () => {
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(
<DoseHistoryVerdictView verdict={verdict} previewOnly={false} />,
);
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");
});
@@ -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() {
<DoseHistoryColumnRulings />
<div aria-live="polite" className="space-y-2">
{verdict && (
{verdict && shouldShowDoseHistoryVerdict(outcome) && (
<DoseHistoryVerdictView verdict={verdict} previewOnly={previewOnly} />
)}
{outcome && <IntakeImportResultView result={outcome} />}
@@ -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,
},
)}
</p>
)}
</div>
@@ -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,
);
});
});
+171 -6
View File
@@ -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<MedicationImportSkipReason, number>
>;
/** 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<string, MedicationImportEntry>();
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();
@@ -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();
},
);
});
@@ -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);
@@ -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");
});
});
+27 -3
View File
@@ -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<string, MedicationMatchCandidate[]>();
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,
@@ -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,
+3 -1
View File
@@ -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,
};
});
}
@@ -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 {
@@ -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);