diff --git a/messages/de.json b/messages/de.json
index 861e0a940..206b9d8f2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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": {
diff --git a/messages/en.json b/messages/en.json
index 7edc56190..48bfe097b 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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": {
diff --git a/messages/es.json b/messages/es.json
index a4c955d17..fcad5dafa 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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": {
diff --git a/messages/fr.json b/messages/fr.json
index cd3cc3217..5d2e86ce7 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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": {
diff --git a/messages/it.json b/messages/it.json
index 858cab7f8..d838baa38 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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": {
diff --git a/messages/pl.json b/messages/pl.json
index 4a8f61594..2fd455604 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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": {
diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts
index 15ae3331b..0633e4262 100644
--- a/src/app/api/admin/status/route.ts
+++ b/src/app/api/admin/status/route.ts
@@ -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,
diff --git a/src/app/api/environment/route.ts b/src/app/api/environment/route.ts
index 2d6e1ceb2..bb447e24b 100644
--- a/src/app/api/environment/route.ts
+++ b/src/app/api/environment/route.ts
@@ -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,
});
});
diff --git a/src/app/api/medications/[id]/intake/import/__tests__/fixtures/reported-intake-export.json b/src/app/api/medications/[id]/intake/import/__tests__/fixtures/reported-intake-export.json
new file mode 100644
index 000000000..14463721c
--- /dev/null
+++ b/src/app/api/medications/[id]/intake/import/__tests__/fixtures/reported-intake-export.json
@@ -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 }
+]
diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts
index 92d3e92f6..a11c60a78 100644
--- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts
@@ -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]);
+ });
+});
diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts
index 71ad1f28e..12020d103 100644
--- a/src/app/api/medications/[id]/intake/import/route.ts
+++ b/src/app/api/medications/[id]/intake/import/route.ts
@@ -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,
};
diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx
index e7d143c75..8ff11b29b 100644
--- a/src/components/admin/_shared.tsx
+++ b/src/components/admin/_shared.tsx
@@ -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;
diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx
index 053789cc1..74a67d0d8 100644
--- a/src/components/admin/system-status-section.tsx
+++ b/src/components/admin/system-status-section.tsx
@@ -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 (
+
+ {t("admin.failingJobs.unavailable")}
+
+ {t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
+
+ {queue.queue}{" "}
+
+ {tCount("admin.failingJobs.count", queue.failures, {
+ count: queue.failures,
+ })}
+
+
+ {t("admin.failingJobs.lastFailed", {
+ when: formatDateTime(queue.lastFailedAt),
+ })}
+
+ {queue.lastError}
+
+ {failingJobs.queues.map((queue) => (
+
+ )}
+
{`[
- {"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"}
]`}
- {result && (
- - {result} -
- )} ++ {result.text} +
+ ); + } + + 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 ( ++ {tCount( + "settings.sections.environment.fetchFailed", + data.lastFetchFailure.failures, + { + count: data.lastFetchFailure.failures, + when: formatDateTime(data.lastFetchFailure.lastFailedAt), + }, + )} +
+ )} {/* Attribution (CC BY 4.0, required) */} diff --git a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts index ee5f100df..defcda458 100644 --- a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts +++ b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts @@ -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([]); + }); }); diff --git a/src/lib/jobs/__tests__/medication-intake-import.test.ts b/src/lib/jobs/__tests__/medication-intake-import.test.ts index ce3b81c28..205ecd0ba 100644 --- a/src/lib/jobs/__tests__/medication-intake-import.test.ts +++ b/src/lib/jobs/__tests__/medication-intake-import.test.ts @@ -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 }, + ]); + }); +}); diff --git a/src/lib/jobs/job-failures.ts b/src/lib/jobs/job-failures.ts new file mode 100644 index 000000000..5141bbd1b --- /dev/null +++ b/src/lib/jobs/job-failures.ts @@ -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