mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(medications): make import replay results honest
This commit is contained in:
1 parent
5b695f43ea
commit
271f0ed28a
25 files changed
+607
-27
No files matched your search
+5
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user