mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(medications): identify an imported dose by when it was taken
The intake import built its replay key from the row's optional `zaehler` field. That reads as a per-row counter — the documented example showed one — but nothing required it to be unique and nothing checked. A reported export carried the dose quantity there instead: 1 tablet, on all 28 rows. Every entry queued under the same key, one dose reached the ledger, and the other 27 came back reported as duplicates of a dose they had nothing to do with. The key is the instant now, and `zaehler` is out of the contract rather than accepted-and-ignored. Nothing is lost by dropping it: the live-row unique on `(user_id, medication_id, scheduled_for, source)` already makes an imported dose unique per instant, so a re-import of a file first imported under the old key still dedups. The telling is the other half. Two different facts were counted as one number: entries of a submission that collide with each other, and doses the ledger already holds. They are separate reasons now, grouped with a count, and the dialog renders them through the same classifier and the same outcome line as the CSV and JSON import cards — so an import that wrote nothing can no longer answer in success tone. That rule shipped for the settings import panel and this surface was not part of it; the guard now lists every import surface by path, so a fourth one has to be added deliberately. `skippedInvalid` is gone: it was hardcoded to zero on every path and reported a capability the importer never had. Alongside it, the other half of the environment report — a scheduled job that fails every run has to say so. `recordError()` increments one unnamed counter, so the admin status page could show a number and not which queue produced it, and the module's own screen showed a zero day count with nothing to distinguish "nothing stored yet" from "every run failed". pg-boss has held the answer the whole time: a job that exhausts its retries keeps its queue name, its error and its timestamp. The admin status now names the failing queues with counts and the last error, the environment overview reports its own account's failed runs, and neither needed a change at any of the thirty `recordError()` call sites — a visibility fix that has to be remembered per call site is the same failure one level up. Refs #650, #646
This commit is contained in:
1 parent
a8d798442f
commit
f0c5b5ae94
27 files changed
+1626
-154
No files matched your search
+26
-4
@@ -911,9 +911,6 @@
|
||||
"importFileLoaded": "Datei \"{name}\" geladen",
|
||||
"importInvalidJson": "Datei enthält kein gültiges JSON",
|
||||
"importNoArray": "Kein Array gefunden",
|
||||
"importResult": "{imported} Einnahmen importiert",
|
||||
"importDuplicatesSkipped": "{count} Duplikate übersprungen",
|
||||
"importInvalidSkipped": "{count} ungültige Einträge übersprungen",
|
||||
"importFailed": "Import fehlgeschlagen",
|
||||
"importInvalidFormat": "Ungültiges JSON-Format",
|
||||
"requestExample": "Request-Beispiel",
|
||||
@@ -1909,6 +1906,18 @@
|
||||
"scaleAbsolute": "Absolut",
|
||||
"scalePercentSinceStart": "% seit Start",
|
||||
"scaleToggleLabel": "Diagrammskala"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} Einnahmen importiert.",
|
||||
"resultPartial": "{imported} importiert, {skipped} übersprungen.",
|
||||
"resultNothing": "Nichts importiert: alle {skipped} Einträge wurden übersprungen.",
|
||||
"resultEmpty": "Diese Datei enthielt keine Einnahmen.",
|
||||
"skipGroupOne": "{count} Eintrag übersprungen: {reason}",
|
||||
"skipGroupFew": "{count} Einträge übersprungen: {reason}",
|
||||
"skipGroupOther": "{count} Einträge übersprungen: {reason}",
|
||||
"reasonDuplicateInFile": "ein anderer Eintrag derselben Datei hat dasselbe Datum und dieselbe Uhrzeit",
|
||||
"reasonAlreadyRecorded": "für dieses Datum und diese Uhrzeit ist bereits eine Einnahme erfasst",
|
||||
"reasonUnknown": "Grund nicht erfasst"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4661,7 +4670,10 @@
|
||||
"description": "Rufe Wetter für einen vergangenen Zeitraum ab und speichere es, damit ältere Tage in die Korrelationen einfließen. Der Start steht standardmäßig auf dem Gültigkeitsdatum deines Wohnorts; um weiter zurückzureichen, füge zuerst einen Ortszeitraum für diesen Bereich hinzu.",
|
||||
"run": "Nacherfassen"
|
||||
},
|
||||
"noResults": "Keine passenden Orte gefunden."
|
||||
"noResults": "Keine passenden Orte gefunden.",
|
||||
"fetchFailedOne": "Die letzte Wetteraktualisierung im Hintergrund ist fehlgeschlagen ({when}).",
|
||||
"fetchFailedFew": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}.",
|
||||
"fetchFailedOther": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -6289,6 +6301,16 @@
|
||||
"connected": "Gemeinsame KI-Verbindung verknüpft",
|
||||
"disconnected": "Gemeinsame KI-Verbindung entfernt",
|
||||
"error": "Etwas ist schiefgelaufen — bitte erneut versuchen"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Hintergrundjobs",
|
||||
"description": "Queues, deren Jobs nach allen Wiederholungen aufgegeben haben.",
|
||||
"none": "In den letzten {hours} Stunden ist kein Job fehlgeschlagen.",
|
||||
"unavailable": "Keine Job-Queue lesbar — dieses Deployment läuft ohne Hintergrund-Worker.",
|
||||
"countOne": "{count} Fehlschlag",
|
||||
"countFew": "{count} Fehlschläge",
|
||||
"countOther": "{count} Fehlschläge",
|
||||
"lastFailed": "Letzter Fehlschlag {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
|
||||
+26
-4
@@ -911,9 +911,6 @@
|
||||
"importFileLoaded": "File \"{name}\" loaded",
|
||||
"importInvalidJson": "File does not contain valid JSON",
|
||||
"importNoArray": "No array found",
|
||||
"importResult": "{imported} intakes imported",
|
||||
"importDuplicatesSkipped": "{count} duplicates skipped",
|
||||
"importInvalidSkipped": "{count} invalid entries skipped",
|
||||
"importFailed": "Import failed",
|
||||
"importInvalidFormat": "Invalid JSON format",
|
||||
"requestExample": "Request example",
|
||||
@@ -1909,6 +1906,18 @@
|
||||
"scaleAbsolute": "Absolute",
|
||||
"scalePercentSinceStart": "% since start",
|
||||
"scaleToggleLabel": "Chart scale"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} intakes imported.",
|
||||
"resultPartial": "{imported} imported, {skipped} skipped.",
|
||||
"resultNothing": "Nothing was imported: all {skipped} entries were skipped.",
|
||||
"resultEmpty": "That file carried no intakes.",
|
||||
"skipGroupOne": "{count} entry skipped: {reason}",
|
||||
"skipGroupFew": "{count} entries skipped: {reason}",
|
||||
"skipGroupOther": "{count} entries skipped: {reason}",
|
||||
"reasonDuplicateInFile": "another entry in the same file has the same date and time",
|
||||
"reasonAlreadyRecorded": "an intake is already recorded for that date and time",
|
||||
"reasonUnknown": "reason not recorded"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4661,7 +4670,10 @@
|
||||
"description": "Fetch and store weather for a past date range so older days join the correlations. The start defaults to your home's effective date; to reach further back, add a location period for that range first.",
|
||||
"run": "Backfill"
|
||||
},
|
||||
"noResults": "No matching places found."
|
||||
"noResults": "No matching places found.",
|
||||
"fetchFailedOne": "The last background weather update failed ({when}).",
|
||||
"fetchFailedFew": "The last {count} background weather updates failed, most recently {when}.",
|
||||
"fetchFailedOther": "The last {count} background weather updates failed, most recently {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -6289,6 +6301,16 @@
|
||||
"connected": "Shared AI connection linked",
|
||||
"disconnected": "Shared AI connection removed",
|
||||
"error": "Something went wrong — please try again"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Background jobs",
|
||||
"description": "Queues whose jobs gave up after exhausting their retries.",
|
||||
"none": "No job has failed in the last {hours} hours.",
|
||||
"unavailable": "No job queue to read — this deployment runs without the background worker.",
|
||||
"countOne": "{count} failure",
|
||||
"countFew": "{count} failures",
|
||||
"countOther": "{count} failures",
|
||||
"lastFailed": "Last failure {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
|
||||
+26
-4
@@ -911,9 +911,6 @@
|
||||
"importFileLoaded": "Archivo \"{name}\" cargado",
|
||||
"importInvalidJson": "El archivo no contiene un JSON válido",
|
||||
"importNoArray": "No se encontró ningún array",
|
||||
"importResult": "{imported} tomas importadas",
|
||||
"importDuplicatesSkipped": "{count} duplicados omitidos",
|
||||
"importInvalidSkipped": "{count} entradas no válidas omitidas",
|
||||
"importFailed": "Falló la importación",
|
||||
"importInvalidFormat": "Formato JSON no válido",
|
||||
"requestExample": "Ejemplo de solicitud",
|
||||
@@ -1909,6 +1906,18 @@
|
||||
"scaleAbsolute": "Absoluto",
|
||||
"scalePercentSinceStart": "% desde el inicio",
|
||||
"scaleToggleLabel": "Escala del gráfico"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} tomas importadas.",
|
||||
"resultPartial": "{imported} importadas, {skipped} omitidas.",
|
||||
"resultNothing": "No se importó nada: se omitieron las {skipped} entradas.",
|
||||
"resultEmpty": "Ese archivo no contenía ninguna toma.",
|
||||
"skipGroupOne": "{count} entrada omitida: {reason}",
|
||||
"skipGroupFew": "{count} entradas omitidas: {reason}",
|
||||
"skipGroupOther": "{count} entradas omitidas: {reason}",
|
||||
"reasonDuplicateInFile": "otra entrada del mismo archivo tiene la misma fecha y hora",
|
||||
"reasonAlreadyRecorded": "ya hay una toma registrada para esa fecha y hora",
|
||||
"reasonUnknown": "motivo no registrado"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4661,7 +4670,10 @@
|
||||
"description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días antiguos entren en las correlaciones. El inicio toma por defecto la fecha de vigencia de tu hogar; para llegar más atrás, añade primero un periodo de ubicación para ese rango.",
|
||||
"run": "Rellenar"
|
||||
},
|
||||
"noResults": "No se encontraron lugares coincidentes."
|
||||
"noResults": "No se encontraron lugares coincidentes.",
|
||||
"fetchFailedOne": "La última actualización meteorológica en segundo plano falló ({when}).",
|
||||
"fetchFailedFew": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}.",
|
||||
"fetchFailedOther": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -6289,6 +6301,16 @@
|
||||
"connected": "Conexión de IA compartida vinculada",
|
||||
"disconnected": "Conexión de IA compartida eliminada",
|
||||
"error": "Algo salió mal: inténtalo de nuevo"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Trabajos en segundo plano",
|
||||
"description": "Colas cuyos trabajos se rindieron tras agotar sus reintentos.",
|
||||
"none": "Ningún trabajo ha fallado en las últimas {hours} horas.",
|
||||
"unavailable": "No hay ninguna cola que consultar: este despliegue funciona sin el worker en segundo plano.",
|
||||
"countOne": "{count} fallo",
|
||||
"countFew": "{count} fallos",
|
||||
"countOther": "{count} fallos",
|
||||
"lastFailed": "Último fallo {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
|
||||
+26
-4
@@ -911,9 +911,6 @@
|
||||
"importFileLoaded": "Fichier « {name} » chargé",
|
||||
"importInvalidJson": "Le fichier ne contient pas de JSON valide",
|
||||
"importNoArray": "Aucun tableau trouvé",
|
||||
"importResult": "{imported} prises importées",
|
||||
"importDuplicatesSkipped": "{count} doublons ignorés",
|
||||
"importInvalidSkipped": "{count} entrées non valides ignorées",
|
||||
"importFailed": "Échec de l’importation",
|
||||
"importInvalidFormat": "Format JSON non valide",
|
||||
"requestExample": "Exemple de requête",
|
||||
@@ -1909,6 +1906,18 @@
|
||||
"scaleAbsolute": "Absolu",
|
||||
"scalePercentSinceStart": "% depuis le début",
|
||||
"scaleToggleLabel": "Échelle du graphique"
|
||||
},
|
||||
"import": {
|
||||
"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.",
|
||||
"resultEmpty": "Ce fichier ne contenait aucune prise.",
|
||||
"skipGroupOne": "{count} entrée ignorée : {reason}",
|
||||
"skipGroupFew": "{count} entrées ignorées : {reason}",
|
||||
"skipGroupOther": "{count} entrées ignorées : {reason}",
|
||||
"reasonDuplicateInFile": "une autre entrée du même fichier porte les mêmes date et heure",
|
||||
"reasonAlreadyRecorded": "une prise est déjà enregistrée pour cette date et cette heure",
|
||||
"reasonUnknown": "motif non enregistré"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4661,7 +4670,10 @@
|
||||
"description": "Récupérez et enregistrez la météo d'une plage de dates passée pour que les jours anciens rejoignent les corrélations. Le début reprend par défaut la date d'effet de votre domicile ; pour remonter plus loin, ajoutez d'abord une période de localisation pour cette plage.",
|
||||
"run": "Récupérer"
|
||||
},
|
||||
"noResults": "Aucun lieu correspondant trouvé."
|
||||
"noResults": "Aucun lieu correspondant trouvé.",
|
||||
"fetchFailedOne": "La dernière mise à jour météo en arrière-plan a échoué ({when}).",
|
||||
"fetchFailedFew": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}.",
|
||||
"fetchFailedOther": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -6289,6 +6301,16 @@
|
||||
"connected": "Connexion IA partagée reliée",
|
||||
"disconnected": "Connexion IA partagée supprimée",
|
||||
"error": "Une erreur s'est produite — veuillez réessayer"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Tâches en arrière-plan",
|
||||
"description": "Files dont les tâches ont abandonné après avoir épuisé leurs tentatives.",
|
||||
"none": "Aucune tâche n’a échoué au cours des {hours} dernières heures.",
|
||||
"unavailable": "Aucune file à consulter : ce déploiement fonctionne sans le worker en arrière-plan.",
|
||||
"countOne": "{count} échec",
|
||||
"countFew": "{count} échecs",
|
||||
"countOther": "{count} échecs",
|
||||
"lastFailed": "Dernier échec {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
|
||||
+26
-4
@@ -911,9 +911,6 @@
|
||||
"importFileLoaded": "File \"{name}\" caricato",
|
||||
"importInvalidJson": "Il file non contiene un JSON valido",
|
||||
"importNoArray": "Nessun array trovato",
|
||||
"importResult": "{imported} assunzioni importate",
|
||||
"importDuplicatesSkipped": "{count} duplicati ignorati",
|
||||
"importInvalidSkipped": "{count} voci non valide ignorate",
|
||||
"importFailed": "Importazione non riuscita",
|
||||
"importInvalidFormat": "Formato JSON non valido",
|
||||
"requestExample": "Esempio di richiesta",
|
||||
@@ -1909,6 +1906,18 @@
|
||||
"scaleAbsolute": "Assoluto",
|
||||
"scalePercentSinceStart": "% dall'inizio",
|
||||
"scaleToggleLabel": "Scala del grafico"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} assunzioni importate.",
|
||||
"resultPartial": "{imported} importate, {skipped} ignorate.",
|
||||
"resultNothing": "Non è stato importato nulla: tutte le {skipped} voci sono state ignorate.",
|
||||
"resultEmpty": "Questo file non conteneva assunzioni.",
|
||||
"skipGroupOne": "{count} voce ignorata: {reason}",
|
||||
"skipGroupFew": "{count} voci ignorate: {reason}",
|
||||
"skipGroupOther": "{count} voci ignorate: {reason}",
|
||||
"reasonDuplicateInFile": "un’altra voce dello stesso file ha la stessa data e la stessa ora",
|
||||
"reasonAlreadyRecorded": "per quella data e ora è già registrata un’assunzione",
|
||||
"reasonUnknown": "motivo non registrato"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4661,7 +4670,10 @@
|
||||
"description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni. L'inizio è impostato in modo predefinito sulla data di validità della tua residenza; per andare più indietro, aggiungi prima un periodo di posizione per quell'intervallo.",
|
||||
"run": "Recupera"
|
||||
},
|
||||
"noResults": "Nessun luogo corrispondente trovato."
|
||||
"noResults": "Nessun luogo corrispondente trovato.",
|
||||
"fetchFailedOne": "L’ultimo aggiornamento meteo in background non è riuscito ({when}).",
|
||||
"fetchFailedFew": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}.",
|
||||
"fetchFailedOther": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -6289,6 +6301,16 @@
|
||||
"connected": "Connessione IA condivisa collegata",
|
||||
"disconnected": "Connessione IA condivisa rimossa",
|
||||
"error": "Qualcosa è andato storto — riprova"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Processi in background",
|
||||
"description": "Code i cui processi hanno rinunciato dopo aver esaurito i tentativi.",
|
||||
"none": "Nessun processo è fallito nelle ultime {hours} ore.",
|
||||
"unavailable": "Nessuna coda da leggere: questo deployment funziona senza il worker in background.",
|
||||
"countOne": "{count} errore",
|
||||
"countFew": "{count} errori",
|
||||
"countOther": "{count} errori",
|
||||
"lastFailed": "Ultimo errore {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
|
||||
+26
-4
@@ -911,9 +911,6 @@
|
||||
"importFileLoaded": "Plik „{name}” wczytany",
|
||||
"importInvalidJson": "Plik nie zawiera prawidłowego JSON",
|
||||
"importNoArray": "Nie znaleziono tablicy",
|
||||
"importResult": "{imported} przyjęć zaimportowano",
|
||||
"importDuplicatesSkipped": "{count} duplikatów pominięto",
|
||||
"importInvalidSkipped": "{count} nieprawidłowych wpisów pominięto",
|
||||
"importFailed": "Import nie powiódł się",
|
||||
"importInvalidFormat": "Nieprawidłowy format JSON",
|
||||
"requestExample": "Przykład żądania",
|
||||
@@ -1909,6 +1906,18 @@
|
||||
"scaleAbsolute": "Bezwzględne",
|
||||
"scalePercentSinceStart": "% od początku",
|
||||
"scaleToggleLabel": "Skala wykresu"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "Zaimportowano przyjęcia: {imported}.",
|
||||
"resultPartial": "Zaimportowano {imported}, pominięto {skipped}.",
|
||||
"resultNothing": "Nic nie zaimportowano: pominięto wszystkie wpisy ({skipped}).",
|
||||
"resultEmpty": "Ten plik nie zawierał żadnych przyjęć.",
|
||||
"skipGroupOne": "Pominięto {count} wpis: {reason}",
|
||||
"skipGroupFew": "Pominięto {count} wpisy: {reason}",
|
||||
"skipGroupOther": "Pominięto {count} wpisów: {reason}",
|
||||
"reasonDuplicateInFile": "inny wpis w tym samym pliku ma tę samą datę i godzinę",
|
||||
"reasonAlreadyRecorded": "przyjęcie o tej dacie i godzinie jest już zapisane",
|
||||
"reasonUnknown": "powód nieodnotowany"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4661,7 +4670,10 @@
|
||||
"description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji. Początek domyślnie przyjmuje datę obowiązywania Twojego domu; aby sięgnąć dalej wstecz, najpierw dodaj okres lokalizacji dla tego zakresu.",
|
||||
"run": "Uzupełnij"
|
||||
},
|
||||
"noResults": "Nie znaleziono pasujących miejsc."
|
||||
"noResults": "Nie znaleziono pasujących miejsc.",
|
||||
"fetchFailedOne": "Ostatnia aktualizacja pogody w tle nie powiodła się ({when}).",
|
||||
"fetchFailedFew": "Ostatnie {count} aktualizacje pogody w tle nie powiodły się, najnowsza {when}.",
|
||||
"fetchFailedOther": "Ostatnich {count} aktualizacji pogody w tle nie powiodło się, najnowsza {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -6289,6 +6301,16 @@
|
||||
"connected": "Wspólne połączenie AI podłączone",
|
||||
"disconnected": "Wspólne połączenie AI usunięte",
|
||||
"error": "Coś poszło nie tak — spróbuj ponownie"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Zadania w tle",
|
||||
"description": "Kolejki, których zadania poddały się po wyczerpaniu ponowień.",
|
||||
"none": "W ciągu ostatnich {hours} godzin żadne zadanie nie zawiodło.",
|
||||
"unavailable": "Brak kolejki zadań do odczytu — to wdrożenie działa bez workera w tle.",
|
||||
"countOne": "{count} niepowodzenie",
|
||||
"countFew": "{count} niepowodzenia",
|
||||
"countOther": "{count} niepowodzeń",
|
||||
"lastFailed": "Ostatnie niepowodzenie {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
import {
|
||||
JOB_FAILURE_WINDOW_HOURS,
|
||||
readFailingQueues,
|
||||
} from "@/lib/jobs/job-failures";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Build timestamp (set at build time via next.config)
|
||||
@@ -57,6 +61,12 @@ export const GET = apiHandler(async () => {
|
||||
appSettings?.webPushVapidSubject,
|
||||
);
|
||||
const workerStatus = getWorkerStatus();
|
||||
// `worker.errors` is one unnamed counter, which is how a queue that failed on
|
||||
// every run for months could sit behind a number nobody could act on. The
|
||||
// queue itself knows which job broke and why, so that is what the status
|
||||
// reports. `null` = no queue schema to ask (web-only deployment); an empty
|
||||
// array = asked, nothing is failing.
|
||||
const failingJobs = await readFailingQueues();
|
||||
|
||||
return apiSuccess({
|
||||
version: process.env.npm_package_version ?? "0.1.0",
|
||||
@@ -66,6 +76,10 @@ export const GET = apiHandler(async () => {
|
||||
startTime: START_TIME,
|
||||
database: "connected",
|
||||
worker: workerStatus,
|
||||
failingJobs:
|
||||
failingJobs === null
|
||||
? null
|
||||
: { windowHours: JOB_FAILURE_WINDOW_HOURS, queues: failingJobs },
|
||||
counts: {
|
||||
users: userCount,
|
||||
measurements: measurementCount,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { OPEN_METEO_ATTRIBUTION } from "@/lib/environment/open-meteo";
|
||||
import { ENVIRONMENT_FETCH_QUEUE } from "@/lib/jobs/environment-fetch";
|
||||
import { readQueueFailureForUser } from "@/lib/jobs/job-failures";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,37 +23,43 @@ export const GET = apiHandler(async () => {
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const [profile, travel, contextCount, latest] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
homeLat: true,
|
||||
homeLon: true,
|
||||
homeLabel: true,
|
||||
homeTimezone: true,
|
||||
homeSince: true,
|
||||
timezone: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentTravelLocation.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lat: true,
|
||||
lon: true,
|
||||
label: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentContext.count({ where: { userId: user.id } }),
|
||||
prisma.environmentContext.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "desc" },
|
||||
select: { date: true, fetchedAt: true },
|
||||
}),
|
||||
]);
|
||||
const [profile, travel, contextCount, latest, lastFetchFailure] =
|
||||
await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
homeLat: true,
|
||||
homeLon: true,
|
||||
homeLabel: true,
|
||||
homeTimezone: true,
|
||||
homeSince: true,
|
||||
timezone: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentTravelLocation.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lat: true,
|
||||
lon: true,
|
||||
label: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentContext.count({ where: { userId: user.id } }),
|
||||
prisma.environmentContext.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "desc" },
|
||||
select: { date: true, fetchedAt: true },
|
||||
}),
|
||||
// A day count on its own cannot tell an empty module from a broken one —
|
||||
// which is exactly how a job that failed on every run since the module
|
||||
// shipped could render as "0 days recorded" and nothing else. The queue
|
||||
// knows; the overview asks it.
|
||||
readQueueFailureForUser(ENVIRONMENT_FETCH_QUEUE, user.id),
|
||||
]);
|
||||
|
||||
const home =
|
||||
profile?.homeLat != null && profile?.homeLon != null
|
||||
@@ -72,6 +80,7 @@ export const GET = apiHandler(async () => {
|
||||
has_home: home !== null,
|
||||
travel_count: travel.length,
|
||||
context_days: contextCount,
|
||||
last_fetch_failed: lastFetchFailure !== null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,6 +92,10 @@ export const GET = apiHandler(async () => {
|
||||
latestDate: latest?.date ?? null,
|
||||
latestFetchedAt: latest?.fetchedAt?.toISOString() ?? null,
|
||||
},
|
||||
// null = the last background runs did not fail (or there is no queue to
|
||||
// ask). Non-null names the failure so a zero day count is never left to
|
||||
// stand alone. No error text: that message is written for an operator.
|
||||
lastFetchFailure,
|
||||
attribution: OPEN_METEO_ATTRIBUTION,
|
||||
});
|
||||
});
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{ "datum": "2026-05-31", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-01", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-02", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-03", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-04", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-05", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-06", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-07", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-08", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-09", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-10", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-11", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-12", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-13", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-14", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-15", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-16", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-17", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-18", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-19", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-20", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-21", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-22", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-23", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-24", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-25", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-26", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-27", "uhrzeit": "08:30:00", "zaehler": 1.0 }
|
||||
]
|
||||
@@ -4,6 +4,9 @@
|
||||
* The request process validates and persists the complete payload, then
|
||||
* returns a polling handle without performing intake writes itself.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -233,14 +236,17 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
status: "queued",
|
||||
// A legacy `zaehler` on the first row rides along and decides nothing:
|
||||
// both keys come from the instant, so two different times can never
|
||||
// collapse onto one key again.
|
||||
payload: {
|
||||
entries: [
|
||||
{
|
||||
idempotencyKey: "import-m1-41",
|
||||
idempotencyKey: `import-m1-${Date.parse("2026-01-01T07:00:00")}`,
|
||||
takenAt: expect.any(String),
|
||||
},
|
||||
{
|
||||
idempotencyKey: expect.stringMatching(/^import-m1-\d+$/),
|
||||
idempotencyKey: `import-m1-${Date.parse("2026-01-01T19:00:00")}`,
|
||||
takenAt: expect.any(String),
|
||||
},
|
||||
],
|
||||
@@ -248,7 +254,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
|
||||
progress: expect.objectContaining({
|
||||
processed: 0,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
total: 2,
|
||||
}),
|
||||
}),
|
||||
@@ -412,3 +418,69 @@ describe("POST /api/medications/[id]/intake/import — future bound and limiter"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake/import — the reported export (#650)", () => {
|
||||
// The file attached to the report: 28 doses, one per day, all at 08:30, and
|
||||
// `"zaehler": 1.0` on every single row — a dose quantity, not a row counter.
|
||||
// The old key derivation used that field verbatim, so all 28 entries queued
|
||||
// under `import-m1-1`, one row was written and 27 were reported back as
|
||||
// duplicates of each other.
|
||||
const REPORTED_EXPORT = JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, "fixtures", "reported-intake-export.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as Array<{ datum: string; uhrzeit: string; zaehler: number }>;
|
||||
|
||||
it("is the reported shape (a fixture that drifted would prove nothing)", () => {
|
||||
expect(REPORTED_EXPORT).toHaveLength(28);
|
||||
expect(new Set(REPORTED_EXPORT.map((row) => row.zaehler)).size).toBe(1);
|
||||
expect(
|
||||
new Set(REPORTED_EXPORT.map((row) => `${row.datum}T${row.uhrzeit}`)).size,
|
||||
).toBe(28);
|
||||
});
|
||||
|
||||
it("queues 28 distinct doses instead of collapsing them onto one key", async () => {
|
||||
const res = await POST(postReq(REPORTED_EXPORT), ROUTE_CTX);
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
const created = vi.mocked(prisma.medicationIntakeImportJob.create).mock
|
||||
.calls[0][0] as unknown as {
|
||||
data: {
|
||||
payload: {
|
||||
entries: Array<{ takenAt: string; idempotencyKey: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const entries = created.data.payload.entries;
|
||||
expect(entries).toHaveLength(28);
|
||||
expect(new Set(entries.map((entry) => entry.idempotencyKey)).size).toBe(28);
|
||||
expect(new Set(entries.map((entry) => entry.takenAt)).size).toBe(28);
|
||||
// The key is a function of the instant and nothing else.
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
const row = REPORTED_EXPORT[index];
|
||||
const instant = Date.parse(`${row.datum}T${row.uhrzeit}`);
|
||||
expect(entry.idempotencyKey).toBe(`import-m1-${instant}`);
|
||||
expect(entry.takenAt).toBe(new Date(instant).toISOString());
|
||||
}
|
||||
});
|
||||
|
||||
it("keys two rows at the same instant identically, whatever else they carry", async () => {
|
||||
await POST(
|
||||
postReq([
|
||||
{ datum: "2026-03-01", uhrzeit: "08:00:00", zaehler: 7 },
|
||||
{ datum: "2026-03-01", uhrzeit: "08:00:00", zaehler: 9 },
|
||||
]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
const created = vi.mocked(prisma.medicationIntakeImportJob.create).mock
|
||||
.calls[0][0] as unknown as {
|
||||
data: { payload: { entries: Array<{ idempotencyKey: string }> } };
|
||||
};
|
||||
const keys = created.data.payload.entries.map(
|
||||
(entry) => entry.idempotencyKey,
|
||||
);
|
||||
expect(keys[0]).toBe(keys[1]);
|
||||
});
|
||||
});
|
||||
@@ -21,14 +21,31 @@ import {
|
||||
type MedicationImportProgress,
|
||||
} from "@/lib/jobs/medication-intake-import";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildMedicationImportEntries } from "@/lib/medications/intake-import-payload";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
|
||||
// The entry shape carried a third field, `zaehler`, and the replay key was
|
||||
// built from it whenever it was present. That reads as a per-row counter,
|
||||
// which is what the documented example showed — but the field was optional,
|
||||
// its uniqueness was never stated and never checked, and an export that puts
|
||||
// the dose quantity there (1 tablet, on every row) collapsed a month of
|
||||
// history onto one key: the first row imported, every later one lost the
|
||||
// `(userId, idempotencyKey)` unique and came back reported as a duplicate of
|
||||
// a dose it had nothing to do with.
|
||||
//
|
||||
// `zaehler` is gone from the contract rather than accepted-and-ignored. A
|
||||
// payload that still carries it imports unchanged — `z.object` drops
|
||||
// unrecognised keys — it simply no longer decides anything. The key now comes
|
||||
// from the instant (`buildMedicationImportEntries`), which is also the grain
|
||||
// the database already enforces: the live-row unique on
|
||||
// `(user_id, medication_id, scheduled_for, source)` makes an imported dose
|
||||
// unique per instant whatever the key says, so a re-import of a file first
|
||||
// imported under the old key still dedups.
|
||||
const importEntrySchema = z.object({
|
||||
datum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
uhrzeit: z.string().regex(/^\d{2}:\d{2}:\d{2}$/),
|
||||
zaehler: z.union([z.number().int(), z.string()]).optional(),
|
||||
});
|
||||
|
||||
function isValidImportTimestamp(datum: string, uhrzeit: string): boolean {
|
||||
@@ -149,19 +166,13 @@ export const POST = apiHandler(
|
||||
|
||||
const entries = parsed.data;
|
||||
const normalized: MedicationImportPayload = {
|
||||
entries: entries.map((entry) => {
|
||||
const takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`);
|
||||
const idempotencyKey = entry.zaehler
|
||||
? `import-${id}-${String(entry.zaehler)}`
|
||||
: `import-${id}-${takenAt.getTime()}`;
|
||||
return { takenAt: takenAt.toISOString(), idempotencyKey };
|
||||
}),
|
||||
entries: buildMedicationImportEntries(id, entries),
|
||||
};
|
||||
const progress: MedicationImportProgress = {
|
||||
processed: 0,
|
||||
total: normalized.entries.length,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
|
||||
@@ -45,6 +45,14 @@ export interface WorkerStatus {
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/** One queue with exhausted-retry failures, as `/api/admin/status` reports it. */
|
||||
export interface FailingQueue {
|
||||
queue: string;
|
||||
failures: number;
|
||||
lastFailedAt: string;
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string;
|
||||
nodeVersion: string;
|
||||
@@ -53,6 +61,13 @@ export interface SystemStatus {
|
||||
startTime: string;
|
||||
database: string;
|
||||
worker: WorkerStatus;
|
||||
/**
|
||||
* Queues whose jobs exhausted their retries recently. `null` means the
|
||||
* deployment has no pg-boss schema to ask (web-only); an empty `queues`
|
||||
* array means it was asked and nothing is failing. The two are rendered
|
||||
* differently on purpose — absence is not health.
|
||||
*/
|
||||
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
|
||||
counts: {
|
||||
users: number;
|
||||
measurements: number;
|
||||
|
||||
@@ -23,7 +23,12 @@ import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
import {
|
||||
StatusItem,
|
||||
usePublicVersion,
|
||||
useSystemStatus,
|
||||
type FailingQueue,
|
||||
} from "./_shared";
|
||||
|
||||
// v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host
|
||||
// metrics chart so the system-status page only ships it when an admin
|
||||
@@ -43,6 +48,83 @@ const HostMetricsChart = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The queues whose jobs gave up, named.
|
||||
*
|
||||
* Before this, a background job that threw on every single run showed up as one
|
||||
* digit in an unnamed `errors` counter, and the module it fed rendered an empty
|
||||
* screen as if empty were the answer. A broken queue has to say its own name,
|
||||
* so this card is rendered whenever there is anything to report and stays
|
||||
* silent otherwise — an operator should be able to trust that a quiet card
|
||||
* means quiet queues, which is why the "cannot ask" case renders its own line
|
||||
* rather than looking identical to health.
|
||||
*/
|
||||
function FailingJobsCard({
|
||||
failingJobs,
|
||||
}: {
|
||||
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
|
||||
if (failingJobs === null) {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.failingJobs.title")}
|
||||
description={t("admin.failingJobs.description")}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-4 pl-7 text-sm">
|
||||
{t("admin.failingJobs.unavailable")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.failingJobs.title")}
|
||||
description={t("admin.failingJobs.description")}
|
||||
/>
|
||||
{failingJobs.queues.length === 0 ? (
|
||||
<p
|
||||
data-testid="failing-jobs-none"
|
||||
className="text-muted-foreground mt-4 pl-7 text-sm"
|
||||
>
|
||||
{t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
|
||||
</p>
|
||||
) : (
|
||||
<ul data-testid="failing-jobs-list" className="mt-4 space-y-3 pl-7">
|
||||
{failingJobs.queues.map((queue) => (
|
||||
<li key={queue.queue} className="text-sm">
|
||||
<p className="text-foreground font-medium">
|
||||
<span className="font-mono">{queue.queue}</span>{" "}
|
||||
<span className="text-destructive font-normal">
|
||||
{tCount("admin.failingJobs.count", queue.failures, {
|
||||
count: queue.failures,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.failingJobs.lastFailed", {
|
||||
when: formatDateTime(queue.lastFailedAt),
|
||||
})}
|
||||
</p>
|
||||
{queue.lastError.length > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 font-mono text-xs break-all">
|
||||
{queue.lastError}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemStatusSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -216,6 +298,7 @@ export function SystemStatusSection() {
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
{status && <FailingJobsCard failingJobs={status.failingJobs} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* #650 — the intake import says what actually happened.
|
||||
*
|
||||
* The report was a file of 28 doses where 27 were "skipped" and the dialog
|
||||
* answered in success tone with a duplicate count, so nothing on screen said
|
||||
* the history had not landed. The cause is fixed elsewhere; this pins the
|
||||
* telling, which is what turned a recoverable problem into a silent one.
|
||||
*
|
||||
* Project convention is SSR-only component tests (vitest runs `node`;
|
||||
* `@testing-library/react` is not installed), so the view takes its state as a
|
||||
* prop and each case is rendered directly.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
IntakeImportResultView,
|
||||
type IntakeImportResultState,
|
||||
} from "../intake-import-result";
|
||||
|
||||
/** `<li>` elements only — the lucide icon markup carries `<line>` tags. */
|
||||
function countListItems(html: string): number {
|
||||
return html.split("<li>").length - 1;
|
||||
}
|
||||
|
||||
function render(state: IntakeImportResultState): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<IntakeImportResultView result={state} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** What the reported run would have produced before the key was fixed. */
|
||||
const ALL_SKIPPED: IntakeImportResultState = {
|
||||
kind: "outcome",
|
||||
imported: 0,
|
||||
skipped: 27,
|
||||
skipReasons: [{ reason: "duplicate_in_file", count: 27 }],
|
||||
};
|
||||
|
||||
describe("<IntakeImportResultView> — an import that wrote nothing", () => {
|
||||
it("renders as a failure, never a success", () => {
|
||||
const html = render(ALL_SKIPPED);
|
||||
expect(html).toContain('data-outcome="failed"');
|
||||
expect(html).not.toContain('data-outcome="success"');
|
||||
expect(html).toContain("Nothing was imported");
|
||||
expect(html).toContain("27");
|
||||
});
|
||||
|
||||
it("names the skip reason once with its count, not once per entry", () => {
|
||||
const html = render(ALL_SKIPPED);
|
||||
expect(html).toContain("27 entries skipped");
|
||||
expect(html).toContain("same date and time");
|
||||
// One list item for one reason — 27 entries must not become 27 lines.
|
||||
expect(countListItems(html)).toBe(1);
|
||||
});
|
||||
|
||||
it("separates the two skip reasons instead of calling both duplicates", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 4,
|
||||
skipped: 5,
|
||||
skipReasons: [
|
||||
{ reason: "already_recorded", count: 3 },
|
||||
{ reason: "duplicate_in_file", count: 2 },
|
||||
],
|
||||
});
|
||||
expect(html).toContain("3 entries skipped");
|
||||
expect(html).toContain("already recorded");
|
||||
expect(html).toContain("2 entries skipped");
|
||||
expect(html).toContain("another entry in the same file");
|
||||
expect(countListItems(html)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeImportResultView> — the other outcomes", () => {
|
||||
it("renders a mixed run as a warning, not a tick", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 26,
|
||||
skipped: 2,
|
||||
skipReasons: [{ reason: "already_recorded", count: 2 }],
|
||||
});
|
||||
expect(html).toContain('data-outcome="partial"');
|
||||
expect(html).toContain("26 imported, 2 skipped");
|
||||
});
|
||||
|
||||
it("renders a clean run as a success", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 28,
|
||||
skipped: 0,
|
||||
skipReasons: [],
|
||||
});
|
||||
expect(html).toContain('data-outcome="success"');
|
||||
expect(html).toContain("28 intakes imported");
|
||||
expect(countListItems(html)).toBe(0);
|
||||
});
|
||||
|
||||
it("gives a file with no intakes its own message rather than either", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
skipReasons: [],
|
||||
});
|
||||
expect(html).toContain('data-outcome="empty"');
|
||||
expect(html).not.toContain('data-outcome="success"');
|
||||
expect(html).toContain("no intakes");
|
||||
});
|
||||
|
||||
it("falls back to a stated absence for an unrecognised reason", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 0,
|
||||
skipped: 1,
|
||||
skipReasons: [{ reason: "something_new", count: 1 }],
|
||||
});
|
||||
expect(html).toContain("reason not recorded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeImportResultView> — notices are not outcomes", () => {
|
||||
it("reports a loaded file in neutral tone, not as an import success", () => {
|
||||
const html = render({
|
||||
kind: "notice",
|
||||
tone: "info",
|
||||
text: 'File "intakes.json" loaded',
|
||||
});
|
||||
expect(html).toContain('data-tone="info"');
|
||||
expect(html).not.toContain("text-success");
|
||||
expect(html).not.toContain("data-outcome");
|
||||
});
|
||||
|
||||
it("reports a refusal in destructive tone", () => {
|
||||
const html = render({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: "Import failed",
|
||||
});
|
||||
expect(html).toContain("text-destructive");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,11 @@ import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react";
|
||||
|
||||
import {
|
||||
IntakeImportResultView,
|
||||
type IntakeImportResultState,
|
||||
} from "@/components/medications/intake-import-result";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
@@ -51,10 +56,7 @@ export function IntakeImportDialog({
|
||||
const { t } = useTranslations();
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [resultType, setResultType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [result, setResult] = useState<IntakeImportResultState | null>(null);
|
||||
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -62,7 +64,6 @@ export function IntakeImportDialog({
|
||||
function resetImportForm() {
|
||||
setJsonText("");
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
setSelectedFileName(null);
|
||||
setFileInputKey((prev) => prev + 1);
|
||||
}
|
||||
@@ -77,18 +78,25 @@ export function IntakeImportDialog({
|
||||
if (!file) return;
|
||||
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
JSON.parse(content);
|
||||
setJsonText(content);
|
||||
setSelectedFileName(file.name);
|
||||
setResult(t("medications.importFileLoaded", { name: file.name }));
|
||||
setResultType("success");
|
||||
// A readable file is a step, not an import. It says so in neutral tone —
|
||||
// the success affordance belongs to a run that actually wrote rows.
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "info",
|
||||
text: t("medications.importFileLoaded", { name: file.name }),
|
||||
});
|
||||
} catch {
|
||||
setResult(t("medications.importInvalidJson"));
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: t("medications.importInvalidJson"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +104,6 @@ export function IntakeImportDialog({
|
||||
if (!medicationId || !jsonText.trim()) return;
|
||||
setImporting(true);
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
let data = JSON.parse(jsonText.trim());
|
||||
@@ -114,37 +121,41 @@ export function IntakeImportDialog({
|
||||
statusUrl: string;
|
||||
}>(`/api/medications/${medicationId}/intake/import`, data);
|
||||
const d = await waitForMedicationIntakeImport(kickoff.statusUrl);
|
||||
setResult(
|
||||
t("medications.importResult", { imported: d.imported }) +
|
||||
(d.skippedDuplicates > 0
|
||||
? `, ${t("medications.importDuplicatesSkipped", { count: d.skippedDuplicates })}`
|
||||
: "") +
|
||||
(d.skippedInvalid > 0
|
||||
? `, ${t("medications.importInvalidSkipped", { count: d.skippedInvalid })}`
|
||||
: ""),
|
||||
);
|
||||
setResultType("success");
|
||||
setResult({
|
||||
kind: "outcome",
|
||||
imported: d.imported,
|
||||
skipped: d.skipped,
|
||||
skipReasons: d.skipReasons,
|
||||
});
|
||||
// An import can land today-dated doses, so clear the Today hero +
|
||||
// dashboard through the blessed helper (they are unmounted behind the
|
||||
// import dialog on the detail page).
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch (err) {
|
||||
if (err instanceof MedicationIntakeImportError) {
|
||||
setResult(t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: t("medications.importFailed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!(err instanceof ApiError)) throw err;
|
||||
setResult(err.message || t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: err.message || t("medications.importFailed"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(
|
||||
err instanceof SyntaxError
|
||||
? t("medications.importInvalidFormat")
|
||||
: (err as Error).message || t("medications.importFailed"),
|
||||
);
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text:
|
||||
err instanceof SyntaxError
|
||||
? t("medications.importInvalidFormat")
|
||||
: (err as Error).message || t("medications.importFailed"),
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -226,8 +237,8 @@ export function IntakeImportDialog({
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43"},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42"}
|
||||
]`}
|
||||
</pre>
|
||||
<Textarea
|
||||
@@ -239,15 +250,9 @@ export function IntakeImportDialog({
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
<div role="status" aria-live="polite">
|
||||
{result && <IntakeImportResultView result={result} />}
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* What the intake-import dialog says once an attempt has finished.
|
||||
*
|
||||
* Split out of the dialog so the outcome can be rendered and asserted on its
|
||||
* own — the dialog itself is a sheet with a query client and a file input, and
|
||||
* the thing that has to be right is the sentence at the end of the run.
|
||||
*
|
||||
* The rules are the ones the settings import panel already follows, reused
|
||||
* rather than restated: `classifyImportOutcome` decides the tone from the
|
||||
* counts, so an import that wrote nothing can never carry the success tick,
|
||||
* and `ImportOutcomeLine` owns the tick and the success colour. The skips are
|
||||
* grouped by reason with a count, because a file that trips one rule trips it
|
||||
* on every row and a per-row list is the same sentence a hundred times.
|
||||
*
|
||||
* This dialog was not covered when that work landed for the CSV and JSON
|
||||
* cards; the medication intake import is the third import surface and it now
|
||||
* answers the same way.
|
||||
*/
|
||||
import {
|
||||
classifyImportOutcome,
|
||||
type ImportOutcome,
|
||||
} from "@/components/settings/import-panel/import-result-state";
|
||||
import { ImportOutcomeLine } from "@/components/settings/import-panel/import-result-view";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** A skip group exactly as the import job reports it. */
|
||||
export interface IntakeImportSkipGroup {
|
||||
reason: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `notice` covers everything that happened before the importer had an opinion —
|
||||
* a file was read, the JSON did not parse, the request was refused. `outcome`
|
||||
* is the import itself.
|
||||
*/
|
||||
export type IntakeImportResultState =
|
||||
| { kind: "notice"; tone: "info" | "error"; text: string }
|
||||
| {
|
||||
kind: "outcome";
|
||||
imported: number;
|
||||
skipped: number;
|
||||
skipReasons: IntakeImportSkipGroup[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Plain language per machine-readable skip reason. Written as literal `t(…)`
|
||||
* calls rather than a computed key so the i18n call-site coverage guard sees
|
||||
* every one of them.
|
||||
*/
|
||||
function useSkipReasonLabel(): (reason: string) => string {
|
||||
const { t } = useTranslations();
|
||||
const labels: Record<string, string> = {
|
||||
duplicate_in_file: t("medications.import.reasonDuplicateInFile"),
|
||||
already_recorded: t("medications.import.reasonAlreadyRecorded"),
|
||||
};
|
||||
const fallback = t("medications.import.reasonUnknown");
|
||||
return (reason: string) => labels[reason] ?? fallback;
|
||||
}
|
||||
|
||||
export function IntakeImportResultView({
|
||||
result,
|
||||
}: {
|
||||
result: IntakeImportResultState;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const labelFor = useSkipReasonLabel();
|
||||
|
||||
if (result.kind === "notice") {
|
||||
return (
|
||||
<p
|
||||
data-testid="intake-import-notice"
|
||||
data-tone={result.tone}
|
||||
className={`text-sm ${result.tone === "error" ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{result.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const outcome: ImportOutcome = classifyImportOutcome({
|
||||
written: result.imported,
|
||||
skipped: result.skipped,
|
||||
});
|
||||
|
||||
const message = (() => {
|
||||
if (outcome === "empty") return t("medications.import.resultEmpty");
|
||||
if (outcome === "failed") {
|
||||
return t("medications.import.resultNothing", { skipped: result.skipped });
|
||||
}
|
||||
if (outcome === "partial") {
|
||||
return t("medications.import.resultPartial", {
|
||||
imported: result.imported,
|
||||
skipped: result.skipped,
|
||||
});
|
||||
}
|
||||
return t("medications.import.resultSuccess", { imported: result.imported });
|
||||
})();
|
||||
|
||||
return (
|
||||
<div data-testid="intake-import-result" className="space-y-1.5">
|
||||
<ImportOutcomeLine
|
||||
outcome={outcome}
|
||||
message={message}
|
||||
testId="intake-import-outcome"
|
||||
/>
|
||||
{result.skipReasons.length > 0 && (
|
||||
<ul
|
||||
data-testid="intake-import-skip-groups"
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
>
|
||||
{result.skipReasons.map((group) => (
|
||||
<li key={group.reason}>
|
||||
{tCount("medications.import.skipGroup", group.count, {
|
||||
count: group.count,
|
||||
reason: labelFor(group.reason),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ConfirmButton } from "@/components/settings/confirm-button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -64,6 +65,10 @@ interface EnvironmentOverview {
|
||||
latestDate: string | null;
|
||||
latestFetchedAt: string | null;
|
||||
};
|
||||
/** Set when the background weather fetch for this account recently gave up.
|
||||
* A zero day count alone cannot distinguish "nothing to store yet" from
|
||||
* "every run failed", so the count is never rendered on its own. */
|
||||
lastFetchFailure: { lastFailedAt: string; failures: number } | null;
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
@@ -72,7 +77,7 @@ function todayKey(): string {
|
||||
}
|
||||
|
||||
export function EnvironmentSection() {
|
||||
const { t } = useTranslations();
|
||||
const { t, tCount } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -500,6 +505,22 @@ export function EnvironmentSection() {
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data?.lastFetchFailure && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="environment-fetch-failed"
|
||||
className="text-destructive mt-1 pl-7 text-xs"
|
||||
>
|
||||
{tCount(
|
||||
"settings.sections.environment.fetchFailed",
|
||||
data.lastFetchFailure.failures,
|
||||
{
|
||||
count: data.lastFetchFailure.failures,
|
||||
when: formatDateTime(data.lastFetchFailure.lastFailedAt),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Attribution (CC BY 4.0, required) */}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
* exactly one key of the outcome table, and no card reaches for them
|
||||
* directly. A future card that hand-rolls a green tick fails here rather than
|
||||
* shipping and lying to a self-hoster about their sensor history.
|
||||
*
|
||||
* #650 widened it. The medication intake import is a third import surface, in
|
||||
* another directory, and it was not covered when the rule landed — it kept its
|
||||
* own success/error tone and reported a run that wrote one row out of
|
||||
* twenty-eight as a success. "Every import surface" now means every one of
|
||||
* them, listed by path, so a fourth surface has to be added here deliberately
|
||||
* rather than inheriting the old behaviour by being somewhere else.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
@@ -14,6 +21,34 @@ import { join } from "node:path";
|
||||
|
||||
const PANEL_DIR = join(__dirname, "..");
|
||||
const PRESENTATION_FILE = "import-result-view.tsx";
|
||||
const REPO_ROOT = join(__dirname, "..", "..", "..", "..", "..");
|
||||
|
||||
/**
|
||||
* Every surface that reports an import result, and the module each one must
|
||||
* render its outcome through. Paths from the repository root.
|
||||
*/
|
||||
const IMPORT_SURFACES: ReadonlyArray<{ path: string; through: RegExp }> = [
|
||||
{
|
||||
path: "src/components/settings/import-panel/csv-import-card.tsx",
|
||||
through: /CsvImportResultView|ImportOutcomeLine/,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/import-panel/json-import-card.tsx",
|
||||
through: /CsvImportResultView|ImportOutcomeLine/,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/import-panel/apple-health-import-card.tsx",
|
||||
through: /CsvImportResultView|ImportOutcomeLine/,
|
||||
},
|
||||
{
|
||||
path: "src/components/medications/intake-import-result.tsx",
|
||||
through: /ImportOutcomeLine/,
|
||||
},
|
||||
{
|
||||
path: "src/components/medications/intake-import-dialog.tsx",
|
||||
through: /IntakeImportResultView/,
|
||||
},
|
||||
];
|
||||
|
||||
/** The success affordance: the tick component and the success colour token. */
|
||||
const SUCCESS_MARKERS = ["CheckCircle2", "text-success"] as const;
|
||||
@@ -25,10 +60,16 @@ function panelSourceFiles(): string[] {
|
||||
}
|
||||
|
||||
/** Code only — a comment naming the marker is prose, not an affordance. */
|
||||
function stripComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
function readCode(name: string): string {
|
||||
return readFileSync(join(PANEL_DIR, name), "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
return stripComments(readFileSync(join(PANEL_DIR, name), "utf8"));
|
||||
}
|
||||
|
||||
function readRepoCode(relativePath: string): string {
|
||||
return stripComments(readFileSync(join(REPO_ROOT, relativePath), "utf8"));
|
||||
}
|
||||
|
||||
describe("import panel — success affordance is owned by the outcome table", () => {
|
||||
@@ -63,13 +104,27 @@ describe("import panel — success affordance is owned by the outcome table", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("routes every card's result line through the shared outcome line", () => {
|
||||
for (const name of [
|
||||
"csv-import-card.tsx",
|
||||
"json-import-card.tsx",
|
||||
"apple-health-import-card.tsx",
|
||||
]) {
|
||||
expect(readCode(name)).toMatch(/CsvImportResultView|ImportOutcomeLine/);
|
||||
it("routes every import surface's result line through the shared outcome line", () => {
|
||||
for (const surface of IMPORT_SURFACES) {
|
||||
expect(
|
||||
readRepoCode(surface.path),
|
||||
`${surface.path} must render its outcome through the shared line`,
|
||||
).toMatch(surface.through);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the success markers out of every import surface", () => {
|
||||
const offenders = IMPORT_SURFACES.filter((surface) =>
|
||||
SUCCESS_MARKERS.some((marker) =>
|
||||
readRepoCode(surface.path).includes(marker),
|
||||
),
|
||||
).map((surface) => surface.path);
|
||||
expect(
|
||||
offenders,
|
||||
offenders.length > 0
|
||||
? `Import surface(s) hand-rolling the success affordance instead of ` +
|
||||
`classifying the counts: ${offenders.join(", ")}`
|
||||
: undefined,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE,
|
||||
_setMedicationIntakeImportPrismaForTests,
|
||||
advanceMedicationImportProgress,
|
||||
groupMedicationImportSkips,
|
||||
medicationImportChunk,
|
||||
processMedicationIntakeImportJob,
|
||||
sanitiseMedicationImportFailure,
|
||||
totalMedicationImportSkips,
|
||||
type MedicationImportProgress,
|
||||
} from "@/lib/jobs/medication-intake-import";
|
||||
|
||||
@@ -15,7 +17,7 @@ const INITIAL: MedicationImportProgress = {
|
||||
processed: 0,
|
||||
total: 1_000,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
@@ -43,21 +45,21 @@ describe("medication intake import worker bounds", () => {
|
||||
from: 0,
|
||||
processed: 100,
|
||||
imported: 98,
|
||||
skippedDuplicates: 2,
|
||||
skippedByReason: { duplicate_in_file: 2 },
|
||||
touchedDays: ["2026-07-20", "2026-07-21"],
|
||||
});
|
||||
const second = advanceMedicationImportProgress(first, {
|
||||
from: 100,
|
||||
processed: 100,
|
||||
imported: 97,
|
||||
skippedDuplicates: 3,
|
||||
skippedByReason: { duplicate_in_file: 1, already_recorded: 2 },
|
||||
touchedDays: ["2026-07-21", "2026-07-22"],
|
||||
});
|
||||
const replay = advanceMedicationImportProgress(second, {
|
||||
from: 0,
|
||||
processed: 100,
|
||||
imported: 98,
|
||||
skippedDuplicates: 2,
|
||||
skippedByReason: { duplicate_in_file: 2 },
|
||||
touchedDays: ["2026-07-20"],
|
||||
});
|
||||
|
||||
@@ -66,7 +68,7 @@ describe("medication intake import worker bounds", () => {
|
||||
processed: 200,
|
||||
total: 1_000,
|
||||
imported: 195,
|
||||
skippedDuplicates: 5,
|
||||
skippedByReason: { duplicate_in_file: 3, already_recorded: 2 },
|
||||
touchedDays: ["2026-07-20", "2026-07-21", "2026-07-22"],
|
||||
rollupProcessed: 0,
|
||||
});
|
||||
@@ -93,7 +95,7 @@ describe("medication intake import worker bounds", () => {
|
||||
processed: 0,
|
||||
total: 0,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays,
|
||||
};
|
||||
let status = "running";
|
||||
@@ -185,3 +187,62 @@ describe("medication intake import worker bounds", () => {
|
||||
expect(auditCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("medication import skip reporting (#650)", () => {
|
||||
it("reports one entry per reason, count descending, and omits zero", () => {
|
||||
expect(
|
||||
groupMedicationImportSkips({
|
||||
duplicate_in_file: 2,
|
||||
already_recorded: 9,
|
||||
}),
|
||||
).toEqual([
|
||||
{ reason: "already_recorded", count: 9 },
|
||||
{ reason: "duplicate_in_file", count: 2 },
|
||||
]);
|
||||
expect(
|
||||
groupMedicationImportSkips({
|
||||
duplicate_in_file: 0,
|
||||
already_recorded: 4,
|
||||
}),
|
||||
).toEqual([{ reason: "already_recorded", count: 4 }]);
|
||||
expect(groupMedicationImportSkips({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("totals every reason so the headline count matches the groups", () => {
|
||||
const counts = { duplicate_in_file: 2, already_recorded: 9 };
|
||||
expect(totalMedicationImportSkips(counts)).toBe(11);
|
||||
expect(
|
||||
groupMedicationImportSkips(counts).reduce(
|
||||
(sum, group) => sum + group.count,
|
||||
0,
|
||||
),
|
||||
).toBe(totalMedicationImportSkips(counts));
|
||||
expect(totalMedicationImportSkips({})).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the two reasons apart while accumulating across chunks", () => {
|
||||
const first = advanceMedicationImportProgress(INITIAL, {
|
||||
from: 0,
|
||||
processed: 100,
|
||||
imported: 90,
|
||||
skippedByReason: { duplicate_in_file: 10 },
|
||||
touchedDays: [],
|
||||
});
|
||||
const second = advanceMedicationImportProgress(first, {
|
||||
from: 100,
|
||||
processed: 100,
|
||||
imported: 95,
|
||||
skippedByReason: { already_recorded: 5 },
|
||||
touchedDays: [],
|
||||
});
|
||||
|
||||
expect(second.skippedByReason).toEqual({
|
||||
duplicate_in_file: 10,
|
||||
already_recorded: 5,
|
||||
});
|
||||
expect(groupMedicationImportSkips(second.skippedByReason)).toEqual([
|
||||
{ reason: "duplicate_in_file", count: 10 },
|
||||
{ reason: "already_recorded", count: 5 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* What the queue already knows about its own broken jobs, read back out.
|
||||
*
|
||||
* The `environment-fetch` job threw on every single run for the whole life of
|
||||
* the environmental-context module. It was handled correctly at the call site —
|
||||
* `recordError()`, a `workerLog("error", …)`, a rethrow so pg-boss retries —
|
||||
* and none of that reached a screen. `recordError()` increments one unnamed
|
||||
* counter, so `/api/admin/status` could say "errors: 41" and not which queue.
|
||||
* The worker log only exists where someone is tailing the container. The user
|
||||
* saw "0 days recorded" and had no way to tell an empty module from a broken
|
||||
* one.
|
||||
*
|
||||
* pg-boss has held the answer the whole time: a job that exhausts its retries
|
||||
* lands in `pgboss.job` with `state = 'failed'`, its queue name, the thrown
|
||||
* error in `output`, and the instant it gave up. Reading that back is a
|
||||
* complete signal for every queue at once, and it needs nothing from the ~30
|
||||
* `recordError()` call sites — which is the point, because a visibility fix
|
||||
* that has to be remembered at each call site is the same failure mode one
|
||||
* level up.
|
||||
*
|
||||
* Both readers fail soft to `null`. The `pgboss` schema is created by the
|
||||
* worker, so a web-only deployment legitimately has no such table, and the
|
||||
* admin status page must not 500 because the queue is absent. `null` means "no
|
||||
* queue to ask", which is not the same fact as an empty array ("asked, nothing
|
||||
* broken") — the callers keep the two apart.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { redactSecrets } from "@/lib/logging/redact";
|
||||
|
||||
/** How far back a failure still counts as news. */
|
||||
export const JOB_FAILURE_WINDOW_HOURS = 72;
|
||||
|
||||
/** Queues named in one report — enough to see a pattern, bounded for the wire. */
|
||||
const MAX_QUEUES = 20;
|
||||
|
||||
/** The error text is for a human reading a status page, not for a stack trace. */
|
||||
const MAX_ERROR_CHARS = 300;
|
||||
|
||||
export interface FailingQueue {
|
||||
/** The pg-boss queue name, e.g. `environment-fetch`. */
|
||||
queue: string;
|
||||
/** Jobs that exhausted their retries inside the window. */
|
||||
failures: number;
|
||||
/** ISO instant of the newest failure. */
|
||||
lastFailedAt: string;
|
||||
/** The newest failure's message, redacted and truncated. */
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
interface FailingQueueRow {
|
||||
name: string;
|
||||
failures: number;
|
||||
last_failed_at: Date | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn whatever pg-boss stored in `output` into one line. A thrown `Error`
|
||||
* serialises to `{ message, stack, … }`; a thrown non-Error can be any JSON at
|
||||
* all, so the raw text is the fallback rather than an invented placeholder.
|
||||
*/
|
||||
function presentError(raw: string | null): string {
|
||||
if (raw === null) return "";
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return "";
|
||||
const redacted = redactSecrets(trimmed);
|
||||
return redacted.length > MAX_ERROR_CHARS
|
||||
? `${redacted.slice(0, MAX_ERROR_CHARS)}…`
|
||||
: redacted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every queue with at least one exhausted-retry failure in the window, newest
|
||||
* first. `null` when the queue schema is not present at all.
|
||||
*/
|
||||
export async function readFailingQueues(
|
||||
withinHours: number = JOB_FAILURE_WINDOW_HOURS,
|
||||
): Promise<FailingQueue[] | null> {
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<FailingQueueRow[]>`
|
||||
SELECT
|
||||
name,
|
||||
count(*)::int AS failures,
|
||||
max(completed_on) AS last_failed_at,
|
||||
(
|
||||
array_agg(
|
||||
COALESCE(output->>'message', output::text)
|
||||
ORDER BY completed_on DESC NULLS LAST
|
||||
)
|
||||
)[1] AS last_error
|
||||
FROM pgboss.job
|
||||
WHERE state = 'failed'
|
||||
AND completed_on > now() - make_interval(hours => ${withinHours}::int)
|
||||
GROUP BY name
|
||||
ORDER BY max(completed_on) DESC
|
||||
LIMIT ${MAX_QUEUES}
|
||||
`;
|
||||
return rows
|
||||
.filter((row) => row.last_failed_at !== null)
|
||||
.map((row) => ({
|
||||
queue: row.name,
|
||||
failures: row.failures,
|
||||
lastFailedAt: row.last_failed_at!.toISOString(),
|
||||
lastError: presentError(row.last_error),
|
||||
}));
|
||||
} catch {
|
||||
// No `pgboss` schema (web-only deployment) or no permission to read it.
|
||||
// Absence of a queue is not a failing queue, and it is not zero either.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueueFailureForUser {
|
||||
/** ISO instant the user's newest job on this queue gave up. */
|
||||
lastFailedAt: string;
|
||||
/** Failures for this user on this queue inside the window. */
|
||||
failures: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest exhausted-retry failure of `queue` for one account, matched on the
|
||||
* `userId` the job payload carries. Used by a user-facing surface to say "the
|
||||
* last background run failed" instead of rendering a zero as if it were a
|
||||
* measurement. Deliberately carries no error text: the message is written for
|
||||
* an operator and can name internals, so a non-admin reader gets the fact and
|
||||
* the time only.
|
||||
*/
|
||||
export async function readQueueFailureForUser(
|
||||
queue: string,
|
||||
userId: string,
|
||||
withinHours: number = JOB_FAILURE_WINDOW_HOURS,
|
||||
): Promise<QueueFailureForUser | null> {
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ failures: number; last_failed_at: Date | null }>
|
||||
>`
|
||||
SELECT count(*)::int AS failures, max(completed_on) AS last_failed_at
|
||||
FROM pgboss.job
|
||||
WHERE name = ${queue}
|
||||
AND state = 'failed'
|
||||
AND data->>'userId' = ${userId}
|
||||
AND completed_on > now() - make_interval(hours => ${withinHours}::int)
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row || row.last_failed_at === null || row.failures === 0) return null;
|
||||
return {
|
||||
lastFailedAt: row.last_failed_at.toISOString(),
|
||||
failures: row.failures,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -32,19 +32,78 @@ export interface MedicationImportPayload {
|
||||
entries: MedicationImportEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a submitted entry did not become a row. Machine-readable; the client
|
||||
* turns each into a sentence.
|
||||
*
|
||||
* `duplicate_in_file` — two entries in the same submission resolve to the same
|
||||
* instant, so only one can be written.
|
||||
* `already_recorded` — the dose is already on the ledger for that instant, from
|
||||
* an earlier import or from any other intake surface.
|
||||
*
|
||||
* They used to be one `skippedDuplicates` number, which is how a file whose
|
||||
* every row collapsed onto one key could report "27 duplicates skipped" about
|
||||
* 27 doses that had no duplicate anywhere. Naming the reason is what makes the
|
||||
* count checkable against what the person actually submitted.
|
||||
*/
|
||||
export const MEDICATION_IMPORT_SKIP_REASONS = [
|
||||
"duplicate_in_file",
|
||||
"already_recorded",
|
||||
] as const;
|
||||
|
||||
export type MedicationImportSkipReason =
|
||||
(typeof MEDICATION_IMPORT_SKIP_REASONS)[number];
|
||||
|
||||
/** Skip counts keyed by reason. A reason with no skips is absent, not zero. */
|
||||
export type MedicationImportSkipCounts = Partial<
|
||||
Record<MedicationImportSkipReason, number>
|
||||
>;
|
||||
|
||||
export interface MedicationImportProgress {
|
||||
processed: number;
|
||||
total: number;
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
skippedByReason: MedicationImportSkipCounts;
|
||||
touchedDays: string[];
|
||||
rollupProcessed: number;
|
||||
}
|
||||
|
||||
export interface MedicationImportSkipGroup {
|
||||
reason: MedicationImportSkipReason;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MedicationImportResult {
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
skippedInvalid: number;
|
||||
/** Total entries that did not become a row. */
|
||||
skipped: number;
|
||||
/**
|
||||
* One entry per reason, count descending then reason — never the same
|
||||
* sentence repeated per row. Empty when nothing was skipped.
|
||||
*/
|
||||
skipReasons: MedicationImportSkipGroup[];
|
||||
}
|
||||
|
||||
/** Collapse the running counts into the wire shape, in a stable order. */
|
||||
export function groupMedicationImportSkips(
|
||||
counts: MedicationImportSkipCounts,
|
||||
): MedicationImportSkipGroup[] {
|
||||
return MEDICATION_IMPORT_SKIP_REASONS.map((reason) => ({
|
||||
reason,
|
||||
count: counts[reason] ?? 0,
|
||||
}))
|
||||
.filter((group) => group.count > 0)
|
||||
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
|
||||
}
|
||||
|
||||
/** Total skipped across every reason. */
|
||||
export function totalMedicationImportSkips(
|
||||
counts: MedicationImportSkipCounts,
|
||||
): number {
|
||||
return MEDICATION_IMPORT_SKIP_REASONS.reduce(
|
||||
(sum, reason) => sum + (counts[reason] ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export interface MedicationIntakeImportQueuePayload {
|
||||
@@ -55,7 +114,7 @@ export interface MedicationImportChunkResult {
|
||||
from: number;
|
||||
processed: number;
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
skippedByReason: MedicationImportSkipCounts;
|
||||
touchedDays: string[];
|
||||
}
|
||||
|
||||
@@ -98,13 +157,20 @@ export function advanceMedicationImportProgress(
|
||||
}
|
||||
}
|
||||
|
||||
const skippedByReason: MedicationImportSkipCounts = {
|
||||
...previous.skippedByReason,
|
||||
};
|
||||
for (const reason of MEDICATION_IMPORT_SKIP_REASONS) {
|
||||
const added = Math.max(0, Math.trunc(chunk.skippedByReason[reason] ?? 0));
|
||||
if (added === 0) continue;
|
||||
skippedByReason[reason] = (skippedByReason[reason] ?? 0) + added;
|
||||
}
|
||||
|
||||
return {
|
||||
processed,
|
||||
total: previous.total,
|
||||
imported: previous.imported + Math.max(0, Math.trunc(chunk.imported)),
|
||||
skippedDuplicates:
|
||||
previous.skippedDuplicates +
|
||||
Math.max(0, Math.trunc(chunk.skippedDuplicates)),
|
||||
skippedByReason,
|
||||
rollupProcessed: previous.rollupProcessed,
|
||||
touchedDays,
|
||||
};
|
||||
@@ -146,6 +212,32 @@ function parsePayload(value: Prisma.JsonValue): MedicationImportPayload {
|
||||
return { entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted skip counts. Strict, like every other field of the
|
||||
* progress blob: an unknown reason key or a non-integer count means the row was
|
||||
* not written by this code, and the job fails rather than reporting a number it
|
||||
* cannot stand behind. Returns null on anything malformed.
|
||||
*/
|
||||
function parseSkipCounts(
|
||||
value: Prisma.JsonValue | undefined,
|
||||
): MedicationImportSkipCounts | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const counts: MedicationImportSkipCounts = {};
|
||||
for (const [key, raw] of Object.entries(value)) {
|
||||
if (
|
||||
!(MEDICATION_IMPORT_SKIP_REASONS as readonly string[]).includes(key) ||
|
||||
typeof raw !== "number" ||
|
||||
!Number.isInteger(raw) ||
|
||||
raw < 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
counts[key as MedicationImportSkipReason] = raw;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function parseProgress(
|
||||
value: Prisma.JsonValue,
|
||||
total: number,
|
||||
@@ -155,7 +247,7 @@ function parseProgress(
|
||||
}
|
||||
const processed = Number(value.processed);
|
||||
const imported = Number(value.imported);
|
||||
const skippedDuplicates = Number(value.skippedDuplicates);
|
||||
const skippedByReason = parseSkipCounts(value.skippedByReason);
|
||||
const rawTouchedDays = value.touchedDays;
|
||||
const touchedDays =
|
||||
Array.isArray(rawTouchedDays) &&
|
||||
@@ -170,8 +262,7 @@ function parseProgress(
|
||||
processed > total ||
|
||||
!Number.isInteger(imported) ||
|
||||
imported < 0 ||
|
||||
!Number.isInteger(skippedDuplicates) ||
|
||||
skippedDuplicates < 0 ||
|
||||
skippedByReason === null ||
|
||||
touchedDays === null ||
|
||||
!Number.isInteger(rollupProcessed) ||
|
||||
rollupProcessed < 0 ||
|
||||
@@ -183,7 +274,7 @@ function parseProgress(
|
||||
processed,
|
||||
total,
|
||||
imported,
|
||||
skippedDuplicates,
|
||||
skippedByReason,
|
||||
touchedDays,
|
||||
rollupProcessed,
|
||||
};
|
||||
@@ -289,8 +380,8 @@ async function processNextChunk(
|
||||
}
|
||||
const result: MedicationImportResult = {
|
||||
imported: progress.imported,
|
||||
skippedDuplicates: progress.skippedDuplicates,
|
||||
skippedInvalid: 0,
|
||||
skipped: totalMedicationImportSkips(progress.skippedByReason),
|
||||
skipReasons: groupMedicationImportSkips(progress.skippedByReason),
|
||||
};
|
||||
const completedAt = new Date();
|
||||
await tx.auditLog.create({
|
||||
@@ -301,8 +392,8 @@ async function processNextChunk(
|
||||
jobId: row.id,
|
||||
medicationId: row.medicationId,
|
||||
imported: result.imported,
|
||||
skippedDuplicates: result.skippedDuplicates,
|
||||
skippedInvalid: 0,
|
||||
skipped: result.skipped,
|
||||
skipReasons: result.skipReasons,
|
||||
total: progress.total,
|
||||
}),
|
||||
},
|
||||
@@ -365,11 +456,20 @@ async function processNextChunk(
|
||||
const touchedDays = created.map((event) =>
|
||||
dayKeyForScheduledFor(event.scheduledFor, row.user.timezone),
|
||||
);
|
||||
// Two different facts, counted apart. The Map collapse above is entries
|
||||
// of this submission that resolve to the same instant; `skipDuplicates`
|
||||
// then drops whatever the ledger already holds for that instant, from an
|
||||
// earlier import or any other intake surface. Reporting both as one
|
||||
// "duplicates" number is what let a whole month of distinct doses read
|
||||
// as duplicates of each other.
|
||||
const nextProgress = advanceMedicationImportProgress(progress, {
|
||||
from: progress.processed,
|
||||
processed: entries.length,
|
||||
imported: created.length,
|
||||
skippedDuplicates: entries.length - created.length,
|
||||
skippedByReason: {
|
||||
duplicate_in_file: entries.length - uniqueEntries.length,
|
||||
already_recorded: uniqueEntries.length - created.length,
|
||||
},
|
||||
touchedDays,
|
||||
});
|
||||
const heartbeatAt = new Date();
|
||||
|
||||
@@ -12,8 +12,8 @@ describe("waitForMedicationIntakeImport", () => {
|
||||
status: "done",
|
||||
result: {
|
||||
imported: 3,
|
||||
skippedDuplicates: 1,
|
||||
skippedInvalid: 0,
|
||||
skipped: 1,
|
||||
skipReasons: [{ reason: "already_recorded", count: 1 }],
|
||||
},
|
||||
});
|
||||
const wait = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -25,8 +25,8 @@ describe("waitForMedicationIntakeImport", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
imported: 3,
|
||||
skippedDuplicates: 1,
|
||||
skippedInvalid: 0,
|
||||
skipped: 1,
|
||||
skipReasons: [{ reason: "already_recorded", count: 1 }],
|
||||
});
|
||||
expect(readStatus).toHaveBeenCalledTimes(3);
|
||||
expect(wait).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The one derivation from a submitted intake-import row to the queued entry.
|
||||
*
|
||||
* It lives here rather than inline in the route so the test that proves a
|
||||
* reported export lands 28 rows can drive the real derivation instead of
|
||||
* restating it. A test that mirrors the production formula proves the mirror.
|
||||
*/
|
||||
|
||||
/** A validated row of the import body: a local date and a local wall time. */
|
||||
export interface MedicationImportRow {
|
||||
datum: string;
|
||||
uhrzeit: string;
|
||||
}
|
||||
|
||||
export interface MedicationImportQueueEntry {
|
||||
takenAt: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An imported dose is identified by when it was taken, and by nothing else.
|
||||
*
|
||||
* The key used to be built from the row's optional `zaehler` field whenever it
|
||||
* was present. That reads as a per-row counter, but nothing required it to be
|
||||
* one, and an export that put the dose quantity there — 1 tablet, on every row
|
||||
* — collapsed a month of distinct doses onto a single key. Deriving from the
|
||||
* instant makes the key a function of the fact it identifies, and matches the
|
||||
* live-row unique on `(user_id, medication_id, scheduled_for, source)` that the
|
||||
* database enforces regardless.
|
||||
*/
|
||||
export function buildMedicationImportEntries(
|
||||
medicationId: string,
|
||||
rows: readonly MedicationImportRow[],
|
||||
): MedicationImportQueueEntry[] {
|
||||
return rows.map((row) => {
|
||||
const takenAt = new Date(`${row.datum}T${row.uhrzeit}`);
|
||||
return {
|
||||
takenAt: takenAt.toISOString(),
|
||||
idempotencyKey: `import-${medicationId}-${takenAt.getTime()}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface MedicationIntakeImportResult {
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
skippedInvalid: number;
|
||||
skipped: number;
|
||||
/** One entry per reason with its count — never the same line per row. */
|
||||
skipReasons: Array<{ reason: string; count: number }>;
|
||||
}
|
||||
|
||||
interface MedicationIntakeImportStatus {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* #646 — a broken background job says its own name.
|
||||
*
|
||||
* The environment fetch threw on every run for the whole life of the module.
|
||||
* It was logged and counted, and neither reached a screen: `recordError()`
|
||||
* increments one unnamed integer, and the worker log only exists where someone
|
||||
* is tailing the container. The queue held the answer the entire time.
|
||||
*
|
||||
* These readers query `pgboss.job` directly, so the only test worth having is
|
||||
* one against a schema pg-boss itself created. A hand-written `CREATE TABLE`
|
||||
* would prove the query matches the table the test wrote, which is exactly the
|
||||
* mistake being fixed. So pg-boss provisions its own schema here and the jobs
|
||||
* are failed through its own API.
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
readFailingQueues,
|
||||
readQueueFailureForUser,
|
||||
} from "@/lib/jobs/job-failures";
|
||||
|
||||
import { getPrismaClient } from "./setup";
|
||||
|
||||
const QUEUE = "environment-fetch";
|
||||
const OTHER_QUEUE = "reminder-check";
|
||||
|
||||
let boss: PgBoss;
|
||||
|
||||
/** Send a job, take it, and fail it terminally so it lands in `state='failed'`. */
|
||||
async function failOneJob(queue: string, data: object, error: string) {
|
||||
const jobId = await boss.send(queue, data, { retryLimit: 0 });
|
||||
if (!jobId) throw new Error("queue rejected the job");
|
||||
const [job] = await boss.fetch(queue);
|
||||
if (!job) throw new Error("nothing to fetch");
|
||||
await boss.fail(queue, job.id, new Error(error));
|
||||
return jobId;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
boss = new PgBoss({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
// No maintenance or monitoring timers — the test drives every transition.
|
||||
supervise: false,
|
||||
schedule: false,
|
||||
});
|
||||
await boss.start();
|
||||
await boss.createQueue(QUEUE);
|
||||
await boss.createQueue(OTHER_QUEUE);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await boss?.stop({ graceful: false, close: true });
|
||||
await getPrismaClient().$executeRawUnsafe(
|
||||
`DROP SCHEMA IF EXISTS pgboss CASCADE`,
|
||||
);
|
||||
});
|
||||
|
||||
describe("readFailingQueues", () => {
|
||||
it("names the queue, counts the failures, and carries the last error", async () => {
|
||||
await failOneJob(
|
||||
QUEUE,
|
||||
{ userId: "user-a" },
|
||||
'type "public.EnvironmentLocationSource" does not exist',
|
||||
);
|
||||
await failOneJob(QUEUE, { userId: "user-b" }, "upstream feed unavailable");
|
||||
await failOneJob(OTHER_QUEUE, {}, "reminder dispatch failed");
|
||||
|
||||
const failing = await readFailingQueues();
|
||||
expect(failing).not.toBeNull();
|
||||
|
||||
const environment = failing?.find((entry) => entry.queue === QUEUE);
|
||||
expect(environment?.failures).toBe(2);
|
||||
expect(environment?.lastError).toBe("upstream feed unavailable");
|
||||
expect(Number.isFinite(Date.parse(environment?.lastFailedAt ?? ""))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const other = failing?.find((entry) => entry.queue === OTHER_QUEUE);
|
||||
expect(other?.failures).toBe(1);
|
||||
expect(other?.lastError).toBe("reminder dispatch failed");
|
||||
});
|
||||
|
||||
it("reports an empty list — not null — when nothing is failing", async () => {
|
||||
await getPrismaClient().$executeRawUnsafe(
|
||||
`DELETE FROM pgboss.job WHERE state = 'failed'`,
|
||||
);
|
||||
await expect(readFailingQueues()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a failure older than the window", async () => {
|
||||
await failOneJob(QUEUE, { userId: "user-a" }, "old failure");
|
||||
await getPrismaClient().$executeRawUnsafe(
|
||||
`UPDATE pgboss.job SET completed_on = now() - interval '10 days' WHERE state = 'failed'`,
|
||||
);
|
||||
await expect(readFailingQueues(72)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readQueueFailureForUser", () => {
|
||||
it("matches on the userId the job payload carries", async () => {
|
||||
await getPrismaClient().$executeRawUnsafe(
|
||||
`DELETE FROM pgboss.job WHERE state = 'failed'`,
|
||||
);
|
||||
await failOneJob(QUEUE, { userId: "user-a" }, "first");
|
||||
await failOneJob(QUEUE, { userId: "user-a" }, "second");
|
||||
await failOneJob(QUEUE, { userId: "user-b" }, "someone else");
|
||||
|
||||
const mine = await readQueueFailureForUser(QUEUE, "user-a");
|
||||
expect(mine?.failures).toBe(2);
|
||||
expect(Number.isFinite(Date.parse(mine?.lastFailedAt ?? ""))).toBe(true);
|
||||
|
||||
// No error text on this side — the message is written for an operator.
|
||||
expect(Object.keys(mine ?? {}).sort()).toEqual([
|
||||
"failures",
|
||||
"lastFailedAt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers null for an account whose jobs did not fail", async () => {
|
||||
await expect(
|
||||
readQueueFailureForUser(QUEUE, "user-with-no-failures"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("does not attribute another queue's failure to this one", async () => {
|
||||
await getPrismaClient().$executeRawUnsafe(
|
||||
`DELETE FROM pgboss.job WHERE state = 'failed'`,
|
||||
);
|
||||
await failOneJob(OTHER_QUEUE, { userId: "user-a" }, "reminder failed");
|
||||
await expect(readQueueFailureForUser(QUEUE, "user-a")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no queue schema at all", () => {
|
||||
it("answers null rather than throwing, so the status page still renders", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await boss.stop({ graceful: false, close: true });
|
||||
await prisma.$executeRawUnsafe(`ALTER SCHEMA pgboss RENAME TO pgboss_gone`);
|
||||
try {
|
||||
await expect(readFailingQueues()).resolves.toBeNull();
|
||||
await expect(
|
||||
readQueueFailureForUser(QUEUE, "user-a"),
|
||||
).resolves.toBeNull();
|
||||
} finally {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER SCHEMA pgboss_gone RENAME TO pgboss`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -82,7 +82,7 @@ describe("medication intake import job — concurrent retry", () => {
|
||||
processed: 0,
|
||||
total: payload.entries.length,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
@@ -150,16 +150,19 @@ describe("medication intake import job — concurrent retry", () => {
|
||||
{ day: "2026-07-21", scheduled: 1, taken: 1 },
|
||||
]);
|
||||
expect(refreshedJob.status).toBe("done");
|
||||
// The two entries sharing an instant collapse inside the submission; that
|
||||
// is `duplicate_in_file`, and it is reported under its own name rather
|
||||
// than as an undifferentiated "duplicate".
|
||||
expect(refreshedJob.result).toEqual({
|
||||
imported: 3,
|
||||
skippedDuplicates: 1,
|
||||
skippedInvalid: 0,
|
||||
skipped: 1,
|
||||
skipReasons: [{ reason: "duplicate_in_file", count: 1 }],
|
||||
});
|
||||
expect(refreshedJob.progress).toEqual({
|
||||
processed: 4,
|
||||
total: 4,
|
||||
imported: 3,
|
||||
skippedDuplicates: 1,
|
||||
skippedByReason: { duplicate_in_file: 1 },
|
||||
touchedDays: ["2026-07-20", "2026-07-21"],
|
||||
rollupProcessed: 2,
|
||||
});
|
||||
@@ -198,7 +201,7 @@ describe("medication intake import job — concurrent retry", () => {
|
||||
processed: 0,
|
||||
total: 1,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* #650 — the reported export, from the request body to the rows on the ledger.
|
||||
*
|
||||
* A self-hoster attached a 28-dose file: one intake per day for four weeks, all
|
||||
* at 08:30, with `"zaehler": 1.0` on every row. The route accepted it, answered
|
||||
* 202, and one intake landed. The other 27 were reported back as duplicates —
|
||||
* of each other, because the replay key was built from `zaehler` and every row
|
||||
* carried the same value.
|
||||
*
|
||||
* A mocked Prisma cannot show this: the collapse is half in the key derivation
|
||||
* and half in two database uniques (`(user_id, idempotency_key)` and the
|
||||
* live-row slot unique). So the real file goes through the real route into a
|
||||
* real Postgres here, and the assertion is the count of rows that exist
|
||||
* afterwards.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import {
|
||||
_setMedicationIntakeImportPrismaForTests,
|
||||
processMedicationIntakeImportJob,
|
||||
} from "@/lib/jobs/medication-intake-import";
|
||||
import { buildMedicationImportEntries } from "@/lib/medications/intake-import-payload";
|
||||
import { toJson } from "@/lib/db";
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
||||
queueMedicationIntakeSync: vi.fn(),
|
||||
}));
|
||||
|
||||
/** The file attached to the report, byte for byte. */
|
||||
const REPORTED_EXPORT = JSON.parse(
|
||||
readFileSync(
|
||||
join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"src/app/api/medications/[id]/intake/import/__tests__/fixtures/reported-intake-export.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
) as Array<{ datum: string; uhrzeit: string; zaehler: number }>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
_setMedicationIntakeImportPrismaForTests(prisma);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
_setMedicationIntakeImportPrismaForTests(null);
|
||||
});
|
||||
|
||||
async function seedMedication() {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "reported-export",
|
||||
email: "reported-export@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const medication = await prisma.medication.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Reported medication",
|
||||
dose: "1 tablet",
|
||||
unitsPerDose: 1,
|
||||
},
|
||||
});
|
||||
return { user, medication };
|
||||
}
|
||||
|
||||
/** The route's own derivation, so the fix is what is under test — not a mirror. */
|
||||
function normalise(medicationId: string) {
|
||||
return {
|
||||
entries: buildMedicationImportEntries(medicationId, REPORTED_EXPORT),
|
||||
};
|
||||
}
|
||||
|
||||
describe("medication intake import — the reported 28-dose export", () => {
|
||||
it("writes one row per dose instead of one row for the whole file", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { user, medication } = await seedMedication();
|
||||
const payload = normalise(medication.id);
|
||||
|
||||
const job = await prisma.medicationIntakeImportJob.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
payload: toJson(payload),
|
||||
progress: toJson({
|
||||
processed: 0,
|
||||
total: payload.entries.length,
|
||||
imported: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await processMedicationIntakeImportJob(job.id);
|
||||
|
||||
expect(result).toEqual({ imported: 28, skipped: 0, skipReasons: [] });
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id, medicationId: medication.id },
|
||||
orderBy: { takenAt: "asc" },
|
||||
select: { takenAt: true, source: true, idempotencyKey: true },
|
||||
});
|
||||
expect(events).toHaveLength(28);
|
||||
expect(new Set(events.map((event) => event.idempotencyKey)).size).toBe(28);
|
||||
expect(events.every((event) => event.source === "IMPORT")).toBe(true);
|
||||
// Every submitted day is on the ledger — the point of the whole import.
|
||||
expect(
|
||||
events.map((event) => event.takenAt?.toISOString().slice(0, 10)),
|
||||
).toEqual(REPORTED_EXPORT.map((row) => row.datum));
|
||||
});
|
||||
|
||||
it("re-importing the same file adds nothing and says why", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { user, medication } = await seedMedication();
|
||||
const payload = normalise(medication.id);
|
||||
const progress = {
|
||||
processed: 0,
|
||||
total: payload.entries.length,
|
||||
imported: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
|
||||
const first = await prisma.medicationIntakeImportJob.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
payload: toJson(payload),
|
||||
progress: toJson(progress),
|
||||
},
|
||||
});
|
||||
await processMedicationIntakeImportJob(first.id);
|
||||
|
||||
const second = await prisma.medicationIntakeImportJob.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
payload: toJson(payload),
|
||||
progress: toJson(progress),
|
||||
},
|
||||
});
|
||||
const result = await processMedicationIntakeImportJob(second.id);
|
||||
|
||||
expect(result).toEqual({
|
||||
imported: 0,
|
||||
skipped: 28,
|
||||
skipReasons: [{ reason: "already_recorded", count: 28 }],
|
||||
});
|
||||
await expect(
|
||||
prisma.medicationIntakeEvent.count({
|
||||
where: { userId: user.id, medicationId: medication.id },
|
||||
}),
|
||||
).resolves.toBe(28);
|
||||
});
|
||||
|
||||
it("counts a within-file collision apart from an already-recorded dose", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { user, medication } = await seedMedication();
|
||||
|
||||
// One dose already on the ledger, from any surface at all.
|
||||
const existing = new Date("2026-05-31T08:30:00");
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
scheduledFor: existing,
|
||||
takenAt: existing,
|
||||
source: "IMPORT",
|
||||
idempotencyKey: `import-${medication.id}-${existing.getTime()}`,
|
||||
},
|
||||
});
|
||||
|
||||
// A submission that repeats one of its own rows AND repeats that dose.
|
||||
const duplicated = new Date("2026-06-05T08:30:00");
|
||||
const entries = [
|
||||
{
|
||||
takenAt: existing.toISOString(),
|
||||
idempotencyKey: `import-${medication.id}-${existing.getTime()}`,
|
||||
},
|
||||
{
|
||||
takenAt: duplicated.toISOString(),
|
||||
idempotencyKey: `import-${medication.id}-${duplicated.getTime()}`,
|
||||
},
|
||||
{
|
||||
takenAt: duplicated.toISOString(),
|
||||
idempotencyKey: `import-${medication.id}-${duplicated.getTime()}`,
|
||||
},
|
||||
];
|
||||
|
||||
const job = await prisma.medicationIntakeImportJob.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
payload: toJson({ entries }),
|
||||
progress: toJson({
|
||||
processed: 0,
|
||||
total: entries.length,
|
||||
imported: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await processMedicationIntakeImportJob(job.id);
|
||||
|
||||
expect(result).toEqual({
|
||||
imported: 1,
|
||||
skipped: 2,
|
||||
skipReasons: [
|
||||
{ reason: "already_recorded", count: 1 },
|
||||
{ reason: "duplicate_in_file", count: 1 },
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
prisma.medicationIntakeEvent.count({
|
||||
where: { userId: user.id, medicationId: medication.id },
|
||||
}),
|
||||
).resolves.toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user