mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(status): expose background failures
This commit is contained in:
1 parent
a689329e79
commit
8ece6f39cc
31 files changed
+854
-44
No files matched your search
@@ -780,6 +780,8 @@
|
||||
"hydrated": "Wasser getrunken",
|
||||
"walked": "spazieren",
|
||||
"alcohol": "Alkohol",
|
||||
"caffeine": "Koffein",
|
||||
"nicotine": "Nikotin",
|
||||
"family": "Familie",
|
||||
"friends": "Freunde",
|
||||
"party": "Party",
|
||||
@@ -2107,6 +2109,15 @@
|
||||
"notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar"
|
||||
},
|
||||
"insights": {
|
||||
"pattern": {
|
||||
"dismiss": "Ausblenden",
|
||||
"dismissed": "Dieses Muster bleibt ausgeblendet, bis sich die Evidenz ändert.",
|
||||
"restore": "Wieder anzeigen",
|
||||
"dismissError": "Dieses Muster konnte nicht ausgeblendet werden.",
|
||||
"restoreError": "Dieses Muster konnte nicht wiederhergestellt werden.",
|
||||
"loadError": "Muster konnten nicht geladen werden.",
|
||||
"empty": "Kein aktuelles Muster hat die Evidenzschwelle erreicht."
|
||||
},
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "höher",
|
||||
@@ -5327,6 +5338,7 @@
|
||||
"connected": "Verbunden",
|
||||
"errorReconnect": "Fehler — neu verbinden",
|
||||
"warningServerError": "Sync fehlgeschlagen",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Pausiert — manuell wieder verbinden",
|
||||
"notConnected": "Nicht verbunden",
|
||||
"justNow": "gerade eben",
|
||||
@@ -8856,6 +8868,8 @@
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Was hier gemessen wird (optional)",
|
||||
"targetHint": "Der Zielbereich ist optional und wird als Band im Diagramm dargestellt.",
|
||||
"correlationEnabled": "Für Korrelationssuche verwenden",
|
||||
"correlationEnabledHint": "Fügt diese Metrik als begrenzten täglichen Verhaltenskanal hinzu. Standardmäßig ist dies ausgeschaltet.",
|
||||
"requiredError": "Name und Einheit sind erforderlich",
|
||||
"rangeOrderError": "Der untere Zielwert darf den oberen nicht überschreiten",
|
||||
"save": "Speichern",
|
||||
|
||||
@@ -780,6 +780,8 @@
|
||||
"hydrated": "hydrated",
|
||||
"walked": "walked",
|
||||
"alcohol": "alcohol",
|
||||
"caffeine": "caffeine",
|
||||
"nicotine": "nicotine",
|
||||
"family": "family",
|
||||
"friends": "friends",
|
||||
"party": "party",
|
||||
@@ -2107,6 +2109,15 @@
|
||||
"notAvailableForCompliance": "Comparison N/A for adherence"
|
||||
},
|
||||
"insights": {
|
||||
"pattern": {
|
||||
"dismiss": "Dismiss",
|
||||
"dismissed": "This pattern is dismissed until its evidence changes.",
|
||||
"restore": "Show again",
|
||||
"dismissError": "Could not dismiss this pattern.",
|
||||
"restoreError": "Could not restore this pattern.",
|
||||
"loadError": "Patterns could not be loaded.",
|
||||
"empty": "No current pattern cleared the evidence threshold."
|
||||
},
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "higher",
|
||||
@@ -5327,6 +5338,7 @@
|
||||
"connected": "Connected",
|
||||
"errorReconnect": "Error — reconnect",
|
||||
"warningServerError": "Sync failing",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Paused — reconnect manually",
|
||||
"notConnected": "Not connected",
|
||||
"justNow": "just now",
|
||||
@@ -8856,6 +8868,8 @@
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What this measures (optional)",
|
||||
"targetHint": "The target range is optional and is drawn as a band on the chart.",
|
||||
"correlationEnabled": "Use in correlation discovery",
|
||||
"correlationEnabledHint": "Adds this metric as a bounded daily behaviour channel. It remains off by default.",
|
||||
"requiredError": "Name and unit are required",
|
||||
"rangeOrderError": "Target low must not exceed target high",
|
||||
"save": "Save",
|
||||
|
||||
@@ -780,6 +780,8 @@
|
||||
"hydrated": "bebí agua",
|
||||
"walked": "caminé",
|
||||
"alcohol": "alcohol",
|
||||
"caffeine": "cafeína",
|
||||
"nicotine": "nicotina",
|
||||
"family": "familia",
|
||||
"friends": "amigos",
|
||||
"party": "fiesta",
|
||||
@@ -2107,6 +2109,15 @@
|
||||
"notAvailableForCompliance": "Comparación no disponible para el cumplimiento"
|
||||
},
|
||||
"insights": {
|
||||
"pattern": {
|
||||
"dismiss": "Descartar",
|
||||
"dismissed": "Este patrón queda descartado hasta que cambie la evidencia.",
|
||||
"restore": "Mostrar de nuevo",
|
||||
"dismissError": "No se pudo descartar este patrón.",
|
||||
"restoreError": "No se pudo restaurar este patrón.",
|
||||
"loadError": "No se pudieron cargar los patrones.",
|
||||
"empty": "Ningún patrón actual superó el umbral de evidencia."
|
||||
},
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "subir",
|
||||
@@ -5327,6 +5338,7 @@
|
||||
"connected": "Conectado",
|
||||
"errorReconnect": "Error — reconectar",
|
||||
"warningServerError": "Sincronización con errores",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Pausado — reconectar manualmente",
|
||||
"notConnected": "No conectado",
|
||||
"justNow": "ahora mismo",
|
||||
@@ -8856,6 +8868,8 @@
|
||||
"description": "Descripción",
|
||||
"descriptionPlaceholder": "Qué mide esto (opcional)",
|
||||
"targetHint": "El rango objetivo es opcional y se dibuja como una banda en el gráfico.",
|
||||
"correlationEnabled": "Usar en el análisis de correlaciones",
|
||||
"correlationEnabledHint": "Añade esta métrica como canal diario de conducta con un límite. Está desactivado por defecto.",
|
||||
"requiredError": "El nombre y la unidad son obligatorios",
|
||||
"rangeOrderError": "El objetivo mínimo no puede superar al máximo",
|
||||
"save": "Guardar",
|
||||
|
||||
@@ -780,6 +780,8 @@
|
||||
"hydrated": "bu de l'eau",
|
||||
"walked": "marché",
|
||||
"alcohol": "alcool",
|
||||
"caffeine": "caféine",
|
||||
"nicotine": "nicotine",
|
||||
"family": "famille",
|
||||
"friends": "amis",
|
||||
"party": "fête",
|
||||
@@ -2107,6 +2109,15 @@
|
||||
"notAvailableForCompliance": "Comparaison indisponible pour l’observance"
|
||||
},
|
||||
"insights": {
|
||||
"pattern": {
|
||||
"dismiss": "Ignorer",
|
||||
"dismissed": "Ce schéma reste ignoré jusqu’à ce que les données changent.",
|
||||
"restore": "Afficher à nouveau",
|
||||
"dismissError": "Impossible d’ignorer ce schéma.",
|
||||
"restoreError": "Impossible de restaurer ce schéma.",
|
||||
"loadError": "Impossible de charger les schémas.",
|
||||
"empty": "Aucun schéma actuel n’a franchi le seuil de preuve."
|
||||
},
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "augmenter",
|
||||
@@ -5327,6 +5338,7 @@
|
||||
"connected": "Connecté",
|
||||
"errorReconnect": "Erreur — reconnecter",
|
||||
"warningServerError": "Échec de synchronisation",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "En pause — reconnecter manuellement",
|
||||
"notConnected": "Non connecté",
|
||||
"justNow": "à l’instant",
|
||||
@@ -8856,6 +8868,8 @@
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Ce que cela mesure (facultatif)",
|
||||
"targetHint": "La plage cible est facultative et s'affiche comme une bande sur le graphique.",
|
||||
"correlationEnabled": "Utiliser dans l’analyse des corrélations",
|
||||
"correlationEnabledHint": "Ajoute cette mesure comme canal comportemental quotidien plafonné. Désactivé par défaut.",
|
||||
"requiredError": "Le nom et l'unité sont obligatoires",
|
||||
"rangeOrderError": "La cible basse ne doit pas dépasser la cible haute",
|
||||
"save": "Enregistrer",
|
||||
|
||||
@@ -780,6 +780,8 @@
|
||||
"hydrated": "bevuto acqua",
|
||||
"walked": "camminato",
|
||||
"alcohol": "alcol",
|
||||
"caffeine": "caffeina",
|
||||
"nicotine": "nicotina",
|
||||
"family": "famiglia",
|
||||
"friends": "amici",
|
||||
"party": "festa",
|
||||
@@ -2107,6 +2109,15 @@
|
||||
"notAvailableForCompliance": "Confronto non disponibile per l’aderenza"
|
||||
},
|
||||
"insights": {
|
||||
"pattern": {
|
||||
"dismiss": "Ignora",
|
||||
"dismissed": "Questo schema resta ignorato finché i dati non cambiano.",
|
||||
"restore": "Mostra di nuovo",
|
||||
"dismissError": "Impossibile ignorare questo schema.",
|
||||
"restoreError": "Impossibile ripristinare questo schema.",
|
||||
"loadError": "Impossibile caricare gli schemi.",
|
||||
"empty": "Nessuno schema attuale ha superato la soglia di evidenza."
|
||||
},
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "salire",
|
||||
@@ -5327,6 +5338,7 @@
|
||||
"connected": "Connesso",
|
||||
"errorReconnect": "Errore — riconnetti",
|
||||
"warningServerError": "Sincronizzazione in errore",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "In pausa — riconnettere manualmente",
|
||||
"notConnected": "Non connesso",
|
||||
"justNow": "proprio ora",
|
||||
@@ -8856,6 +8868,8 @@
|
||||
"description": "Descrizione",
|
||||
"descriptionPlaceholder": "Cosa misura (facoltativo)",
|
||||
"targetHint": "L'intervallo obiettivo è facoltativo e viene disegnato come una banda sul grafico.",
|
||||
"correlationEnabled": "Usa nell’analisi delle correlazioni",
|
||||
"correlationEnabledHint": "Aggiunge questa metrica come canale comportamentale giornaliero con un limite. È disattivata per impostazione predefinita.",
|
||||
"requiredError": "Nome e unità sono obbligatori",
|
||||
"rangeOrderError": "L'obiettivo minimo non può superare quello massimo",
|
||||
"save": "Salva",
|
||||
|
||||
@@ -780,6 +780,8 @@
|
||||
"hydrated": "piłem wodę",
|
||||
"walked": "spacer",
|
||||
"alcohol": "alkohol",
|
||||
"caffeine": "kofeina",
|
||||
"nicotine": "nikotyna",
|
||||
"family": "rodzina",
|
||||
"friends": "znajomi",
|
||||
"party": "impreza",
|
||||
@@ -2107,6 +2109,15 @@
|
||||
"notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania"
|
||||
},
|
||||
"insights": {
|
||||
"pattern": {
|
||||
"dismiss": "Odrzuć",
|
||||
"dismissed": "Ten wzorzec pozostanie odrzucony, dopóki nie zmienią się dowody.",
|
||||
"restore": "Pokaż ponownie",
|
||||
"dismissError": "Nie udało się odrzucić tego wzorca.",
|
||||
"restoreError": "Nie udało się przywrócić tego wzorca.",
|
||||
"loadError": "Nie udało się wczytać wzorców.",
|
||||
"empty": "Żaden aktualny wzorzec nie przekroczył progu dowodów."
|
||||
},
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "wzrostu",
|
||||
@@ -5327,6 +5338,7 @@
|
||||
"connected": "Połączono",
|
||||
"errorReconnect": "Błąd — połącz ponownie",
|
||||
"warningServerError": "Synchronizacja nie działa",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Wstrzymane — połącz ręcznie",
|
||||
"notConnected": "Nie połączono",
|
||||
"justNow": "przed chwilą",
|
||||
@@ -8856,6 +8868,8 @@
|
||||
"description": "Opis",
|
||||
"descriptionPlaceholder": "Co to mierzy (opcjonalnie)",
|
||||
"targetHint": "Zakres docelowy jest opcjonalny i jest rysowany jako pasmo na wykresie.",
|
||||
"correlationEnabled": "Używaj w analizie korelacji",
|
||||
"correlationEnabledHint": "Dodaje tę metrykę jako ograniczony dzienny kanał zachowania. Domyślnie jest wyłączona.",
|
||||
"requiredError": "Nazwa i jednostka są wymagane",
|
||||
"rangeOrderError": "Cel dolny nie może przekraczać celu górnego",
|
||||
"save": "Zapisz",
|
||||
|
||||
@@ -44,10 +44,16 @@ vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/job-failures", () => ({
|
||||
JOB_FAILURE_WINDOW_HOURS: 72,
|
||||
readFailingQueues: vi.fn(),
|
||||
}));
|
||||
|
||||
// v1.4.25 W21 Fix-K — `/api/admin/status` now reads the legacy-form
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { readFailingQueues } from "@/lib/jobs/job-failures";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
@@ -69,6 +75,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.session.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(readFailingQueues).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/status (integration health summary)", () => {
|
||||
@@ -148,7 +155,7 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
counts: Record<string, number>;
|
||||
worker: { running: boolean };
|
||||
worker: { running: boolean; errors?: number };
|
||||
database: string;
|
||||
};
|
||||
};
|
||||
@@ -161,10 +168,44 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
activeSessions: 0,
|
||||
});
|
||||
expect(body.data.worker.running).toBe(true);
|
||||
expect(body.data.worker).not.toHaveProperty("errors");
|
||||
expect(body.data.database).toBe("connected");
|
||||
|
||||
// Regression: only NON-revoked tokens are counted as "active".
|
||||
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
|
||||
it("reports named failing queues through failingJobs only", async () => {
|
||||
vi.mocked(readFailingQueues).mockResolvedValueOnce([
|
||||
{
|
||||
queue: "environment-fetch",
|
||||
failures: 2,
|
||||
lastFailedAt: "2026-07-28T12:00:00.000Z",
|
||||
lastError: "upstream unavailable",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
worker: Record<string, unknown>;
|
||||
failingJobs: {
|
||||
windowHours: number;
|
||||
queues: Array<{ queue: string; failures: number }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.data.worker).not.toHaveProperty("errors");
|
||||
expect(body.data.failingJobs).toEqual({
|
||||
windowHours: 72,
|
||||
queues: [
|
||||
expect.objectContaining({
|
||||
queue: "environment-fetch",
|
||||
failures: 2,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,15 @@ export const GET = apiHandler(async () => {
|
||||
buildTime: BUILD_TIME,
|
||||
startTime: START_TIME,
|
||||
database: "connected",
|
||||
worker: workerStatus,
|
||||
worker: {
|
||||
running: workerStatus.running,
|
||||
startedAt: workerStatus.startedAt,
|
||||
lastHeartbeat: workerStatus.lastHeartbeat,
|
||||
lastReminderCheck: workerStatus.lastReminderCheck,
|
||||
lastWithingsSync: workerStatus.lastWithingsSync,
|
||||
lastInsightsRun: workerStatus.lastInsightsRun,
|
||||
jobsProcessed: workerStatus.jobsProcessed,
|
||||
},
|
||||
failingJobs:
|
||||
failingJobs === null
|
||||
? null
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { FailingJobsCard } from "../system-status-section";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FailingJobsCard", () => {
|
||||
it("renders named exhausted-retry failures from failingJobs", () => {
|
||||
const html = render(
|
||||
<FailingJobsCard
|
||||
failingJobs={{
|
||||
windowHours: 72,
|
||||
queues: [
|
||||
{
|
||||
queue: "environment-fetch",
|
||||
failures: 2,
|
||||
lastFailedAt: "2026-07-28T12:00:00.000Z",
|
||||
lastError: "upstream unavailable",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("environment-fetch");
|
||||
expect(html).toContain("2 failures");
|
||||
expect(html).toContain("upstream unavailable");
|
||||
expect(html).toContain('data-testid="failing-jobs-list"');
|
||||
});
|
||||
|
||||
it("distinguishes an unavailable queue ledger from an empty one", () => {
|
||||
const unavailable = render(<FailingJobsCard failingJobs={null} />);
|
||||
const empty = render(
|
||||
<FailingJobsCard failingJobs={{ windowHours: 72, queues: [] }} />,
|
||||
);
|
||||
|
||||
expect(unavailable).toContain("No job queue to read");
|
||||
expect(unavailable).not.toContain('data-testid="failing-jobs-none"');
|
||||
expect(empty).toContain('data-testid="failing-jobs-none"');
|
||||
expect(empty).toContain("No job has failed in the last 72 hours.");
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,6 @@ export interface WorkerStatus {
|
||||
lastWithingsSync: string | null;
|
||||
lastInsightsRun: string | null;
|
||||
jobsProcessed: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/** One queue with exhausted-retry failures, as `/api/admin/status` reports it. */
|
||||
|
||||
@@ -59,7 +59,7 @@ const HostMetricsChart = dynamic(
|
||||
* means quiet queues, which is why the "cannot ask" case renders its own line
|
||||
* rather than looking identical to health.
|
||||
*/
|
||||
function FailingJobsCard({
|
||||
export function FailingJobsCard({
|
||||
failingJobs,
|
||||
}: {
|
||||
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
|
||||
|
||||
@@ -217,6 +217,33 @@ describe("IntegrationStatusPill — a failing provider states its age", () => {
|
||||
expect(html).toContain('data-state="warning"');
|
||||
});
|
||||
|
||||
it("renders the localized failure count against the server threshold", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="warning"
|
||||
lastSyncAt={new Date()}
|
||||
now={new Date()}
|
||||
failureCount={2}
|
||||
failureThreshold={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("2/3");
|
||||
});
|
||||
|
||||
it("omits a partial failure ratio", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="warning"
|
||||
lastSyncAt={new Date()}
|
||||
now={new Date()}
|
||||
failureCount={2}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("2/");
|
||||
});
|
||||
|
||||
it("keeps the German label free of a connection claim too", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
|
||||
@@ -69,6 +69,10 @@ interface IntegrationStatusPillProps {
|
||||
* suffix (used when the integration was just configured but has
|
||||
* never synced yet). */
|
||||
lastSyncAt: Date | string | null;
|
||||
/** Current consecutive-failure count from the active server bucket. */
|
||||
failureCount?: number;
|
||||
/** Server-owned alert threshold paired with `failureCount`. */
|
||||
failureThreshold?: number;
|
||||
/** Override "now" for deterministic testing. Defaults to `new Date()`. */
|
||||
now?: Date;
|
||||
/** Override the testid so a card can keep its own established e2e hook. */
|
||||
@@ -129,6 +133,8 @@ const RELATIVE_STATES = new Set<IntegrationPillState>([
|
||||
export function IntegrationStatusPill({
|
||||
state,
|
||||
lastSyncAt,
|
||||
failureCount,
|
||||
failureThreshold,
|
||||
now,
|
||||
testId,
|
||||
className,
|
||||
@@ -189,6 +195,15 @@ export function IntegrationStatusPill({
|
||||
t,
|
||||
)
|
||||
: null;
|
||||
const failureRatio =
|
||||
failureCount !== undefined &&
|
||||
failureCount > 0 &&
|
||||
failureThreshold !== undefined
|
||||
? t("settings.integrationPill.failureCount", {
|
||||
count: failureCount,
|
||||
threshold: failureThreshold,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
@@ -218,6 +233,7 @@ export function IntegrationStatusPill({
|
||||
{icon}
|
||||
<span className="truncate">
|
||||
{label}
|
||||
{failureRatio ? ` · ${failureRatio}` : null}
|
||||
{relative ? <span aria-hidden="true"> · {relative}</span> : null}
|
||||
</span>
|
||||
{relative ? <span className="sr-only">{relative}</span> : null}
|
||||
|
||||
@@ -13,7 +13,11 @@ import { join, sep } from "node:path";
|
||||
import { globSync } from "node:fs";
|
||||
|
||||
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
|
||||
import { pillStateForVerdict } from "../shared";
|
||||
import {
|
||||
pickStatus,
|
||||
pillFailurePropsFor,
|
||||
pillStateForVerdict,
|
||||
} from "../shared";
|
||||
|
||||
describe("pillStateForVerdict — the verdict→pill table", () => {
|
||||
const table: Array<[SyncVerdict | undefined, string]> = [
|
||||
@@ -50,6 +54,61 @@ describe("pillStateForVerdict — the verdict→pill table", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pillFailurePropsFor", () => {
|
||||
it("projects the active bucket and envelope threshold onto the pill", () => {
|
||||
const status = pickStatus(
|
||||
{
|
||||
threshold: 3,
|
||||
integrations: [
|
||||
{
|
||||
integration: "withings",
|
||||
state: "error_transient",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: "2026-07-28T12:00:00.000Z",
|
||||
lastError: "upstream unavailable",
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
persistent: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"withings",
|
||||
);
|
||||
|
||||
expect(pillFailurePropsFor(status)).toEqual({
|
||||
failureCount: 2,
|
||||
failureThreshold: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no ratio for a healthy zero-count status", () => {
|
||||
const status = pickStatus(
|
||||
{
|
||||
threshold: 3,
|
||||
integrations: [
|
||||
{
|
||||
integration: "withings",
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-07-28T12:00:00.000Z",
|
||||
lastAttemptAt: "2026-07-28T12:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"withings",
|
||||
);
|
||||
|
||||
expect(pillFailurePropsFor(status)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("structural guard — no second pill dialect", () => {
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
|
||||
@@ -185,6 +185,8 @@ function toOAuthStatus(
|
||||
lastAttemptAt: vm.lastAttemptAt,
|
||||
lastError: vm.lastError,
|
||||
legacyLastSyncedAt: vm.legacyLastSyncedAt,
|
||||
consecutiveFailuresByKind: vm.consecutiveFailuresByKind,
|
||||
failureThreshold: vm.failureThreshold,
|
||||
syncHealth: vm.syncHealth,
|
||||
metricFreshness: vm.metricFreshness,
|
||||
};
|
||||
@@ -310,11 +312,16 @@ export function ConnectionsPanel() {
|
||||
pickStatus(integrationStatus, "strava"),
|
||||
);
|
||||
const nightscoutViewModel = pickStatus(integrationStatus, "nightscout");
|
||||
const unclaimedIntegrations = (integrationStatus?.integrations ?? []).filter(
|
||||
(entry) =>
|
||||
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
|
||||
(entry.configured || entry.connected),
|
||||
);
|
||||
const unclaimedIntegrations = (integrationStatus?.integrations ?? [])
|
||||
.filter(
|
||||
(entry) =>
|
||||
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
|
||||
(entry.configured || entry.connected),
|
||||
)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
failureThreshold: integrationStatus?.threshold,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="connections-panel">
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateFor,
|
||||
pillTimestampFor,
|
||||
type IntegrationStatusViewModel,
|
||||
@@ -237,6 +238,7 @@ export function FitbitCard({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillLastSyncAt}
|
||||
{...pillFailurePropsFor(viewModel)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateFor,
|
||||
pillTimestampFor,
|
||||
type IntegrationStatusViewModel,
|
||||
@@ -245,6 +246,7 @@ export function GoogleHealthCard({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillLastSyncAt}
|
||||
{...pillFailurePropsFor(viewModel)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
pillFailurePropsFor,
|
||||
pillStateFor,
|
||||
pillTimestampFor,
|
||||
type IntegrationKey,
|
||||
@@ -89,6 +90,7 @@ export function IntegrationFallbackRow({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillTimestampFor(status)}
|
||||
{...pillFailurePropsFor(status)}
|
||||
now={now}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from "./sync-outcome";
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateForVerdict,
|
||||
pillTimestampFor,
|
||||
type IntegrationStatusViewModel,
|
||||
@@ -210,6 +211,7 @@ export function NightscoutCard({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillTimestampFor(status)}
|
||||
{...pillFailurePropsFor(status)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -58,7 +58,11 @@ import {
|
||||
useSyncOutcomeMessage,
|
||||
type SyncOutcomeState,
|
||||
} from "./sync-outcome";
|
||||
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateForVerdict,
|
||||
} from "./shared";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
IntegrationRedirectGuide,
|
||||
@@ -81,6 +85,12 @@ export interface OAuthProviderStatus {
|
||||
lastAttemptAt?: string | null;
|
||||
lastError?: string | null;
|
||||
legacyLastSyncedAt?: string | null;
|
||||
consecutiveFailuresByKind?: {
|
||||
transient: number;
|
||||
reauth_required: number;
|
||||
persistent: number;
|
||||
} | null;
|
||||
failureThreshold?: number;
|
||||
/** The server-resolved liveness verdict; the pill is a projection of it. */
|
||||
syncHealth?: SyncHealth;
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
@@ -270,6 +280,7 @@ export function OAuthProviderCard({
|
||||
? (status?.syncHealth?.since ?? null)
|
||||
: (status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null)
|
||||
}
|
||||
{...pillFailurePropsFor(status)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface IntegrationStatusViewModel {
|
||||
reauth_required: number;
|
||||
persistent: number;
|
||||
} | null;
|
||||
/** Client-side copy of the envelope threshold, attached by `pickStatus`. */
|
||||
failureThreshold?: number;
|
||||
configured?: boolean;
|
||||
connected?: boolean;
|
||||
connectedAt?: string | null;
|
||||
@@ -110,7 +112,12 @@ export function pickStatus(
|
||||
envelope: IntegrationStatusEnvelope | undefined,
|
||||
integration: IntegrationKey,
|
||||
): IntegrationStatusViewModel | undefined {
|
||||
return envelope?.integrations.find((i) => i.integration === integration);
|
||||
const status = envelope?.integrations.find(
|
||||
(entry) => entry.integration === integration,
|
||||
);
|
||||
return status
|
||||
? { ...status, failureThreshold: envelope?.threshold }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +165,36 @@ export function pillStateFor(
|
||||
return pillStateForVerdict(status?.syncHealth?.verdict);
|
||||
}
|
||||
|
||||
export interface IntegrationPillFailureProps {
|
||||
failureCount?: number;
|
||||
failureThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair the largest per-kind streak with the server-owned alert threshold. The
|
||||
* alert ladder uses the same maximum, so the ratio cannot disagree with when a
|
||||
* notification becomes eligible.
|
||||
*/
|
||||
export function pillFailurePropsFor(
|
||||
status:
|
||||
| Pick<
|
||||
IntegrationStatusViewModel,
|
||||
"consecutiveFailuresByKind" | "failureThreshold"
|
||||
>
|
||||
| undefined,
|
||||
): IntegrationPillFailureProps {
|
||||
const buckets = status?.consecutiveFailuresByKind;
|
||||
const failureThreshold = status?.failureThreshold;
|
||||
if (!buckets || failureThreshold === undefined) return {};
|
||||
|
||||
const failureCount = Math.max(
|
||||
buckets.transient,
|
||||
buckets.reauth_required,
|
||||
buckets.persistent,
|
||||
);
|
||||
return failureCount > 0 ? { failureCount, failureThreshold } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The timestamp the pill shows inline. `connected` reads the last success (a
|
||||
* connection row's own `lastSyncedAt` wins when it is newer, which is what
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateFor,
|
||||
pillTimestampFor,
|
||||
type IntegrationStatusViewModel,
|
||||
@@ -218,6 +219,7 @@ export function WhoopCard({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillLastSyncAt}
|
||||
{...pillFailurePropsFor(viewModel)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillStateFor,
|
||||
pillFailurePropsFor,
|
||||
pillTimestampFor,
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
@@ -230,6 +231,7 @@ export function WithingsCard({
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillLastSyncAt}
|
||||
{...pillFailurePropsFor(viewModel)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,11 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/dispatcher", () => ({
|
||||
dispatchNotification: vi.fn().mockResolvedValue(undefined),
|
||||
dispatchNotification: vi.fn().mockResolvedValue({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { addWarningMock } = vi.hoisted(() => ({
|
||||
addWarningMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => ({ addWarning: addWarningMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
integrationStatus: {
|
||||
@@ -28,7 +37,7 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/dispatcher", () => ({
|
||||
dispatchNotification: vi.fn().mockResolvedValue(undefined),
|
||||
dispatchNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
@@ -56,6 +65,11 @@ import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(dispatchNotification).mockResolvedValue({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
});
|
||||
delete process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD;
|
||||
});
|
||||
|
||||
@@ -207,6 +221,90 @@ describe("recordSyncFailure — at threshold", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not stamp alertedAt when no admin channel is available", async () => {
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
email: "user@example.com",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([
|
||||
{ id: "admin-1" },
|
||||
] as never);
|
||||
vi.mocked(dispatchNotification).mockResolvedValueOnce({
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
|
||||
await recordSyncFailure({
|
||||
userId: "u1",
|
||||
integration: "withings",
|
||||
kind: "transient",
|
||||
message: "Withings refresh error: 503 - upstream service down",
|
||||
errorCode: "503",
|
||||
});
|
||||
|
||||
expect(dispatchNotification).toHaveBeenCalledOnce();
|
||||
expect(prisma.integrationStatus.update).not.toHaveBeenCalled();
|
||||
expect(addWarningMock).toHaveBeenCalledWith(
|
||||
"No admin notification channel available for withings failure alert",
|
||||
);
|
||||
});
|
||||
|
||||
it("stamps alertedAt after an attempted delivery that failed", async () => {
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
email: "user@example.com",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([
|
||||
{ id: "admin-1" },
|
||||
] as never);
|
||||
vi.mocked(dispatchNotification).mockResolvedValueOnce({
|
||||
dispatched: false,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await recordSyncFailure({
|
||||
userId: "u1",
|
||||
integration: "withings",
|
||||
kind: "transient",
|
||||
message: "Withings refresh error: 503 - upstream service down",
|
||||
errorCode: "503",
|
||||
});
|
||||
|
||||
expect(prisma.integrationStatus.update).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId_integration: { userId: "u1", integration: "withings" },
|
||||
},
|
||||
data: { alertedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the custom threshold from INTEGRATION_FAILURE_ALERT_THRESHOLD", async () => {
|
||||
process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "5";
|
||||
// 4 < 5 → should not page yet. Seed the existing bucket at 3 so
|
||||
@@ -398,8 +496,8 @@ describe("recordSyncFailure — persistent (v1.4.42 W6)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordSyncFailure — admin Telegram skipped silently when no admins", () => {
|
||||
it("logs a wide-event warning but does NOT throw when no admin users exist", async () => {
|
||||
describe("recordSyncFailure — no admin recipients", () => {
|
||||
it("surfaces the unavailable delivery and leaves alertedAt unstamped", async () => {
|
||||
// Seed bucket at 2 so the in-memory increment hits the threshold
|
||||
// and the no-admins branch is the one being exercised.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
@@ -418,9 +516,6 @@ describe("recordSyncFailure — admin Telegram skipped silently when no admins",
|
||||
email: "u@example.com",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([] as never);
|
||||
vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
recordSyncFailure({
|
||||
@@ -432,6 +527,10 @@ describe("recordSyncFailure — admin Telegram skipped silently when no admins",
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(dispatchNotification).not.toHaveBeenCalled();
|
||||
expect(prisma.integrationStatus.update).not.toHaveBeenCalled();
|
||||
expect(addWarningMock).toHaveBeenCalledWith(
|
||||
"No admin notification channel available for withings failure alert",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,10 @@ import { prisma } from "@/lib/db";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import {
|
||||
dispatchNotification,
|
||||
type DispatchOutcome,
|
||||
} from "@/lib/notifications/dispatcher";
|
||||
import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier";
|
||||
|
||||
export type IntegrationKey =
|
||||
@@ -755,7 +758,7 @@ export async function recordSyncFailure(
|
||||
row.alertedAt &&
|
||||
now.getTime() - row.alertedAt.getTime() < ALERT_REPEAT_WINDOW_MS;
|
||||
if (!previouslyAlerted) {
|
||||
await maybeAlertAdmins({
|
||||
const outcome = await maybeAlertAdmins({
|
||||
userId,
|
||||
integration,
|
||||
kind: effectiveKind,
|
||||
@@ -766,14 +769,25 @@ export async function recordSyncFailure(
|
||||
getEvent()?.addWarning(
|
||||
`Admin alert dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
// Stamp the alert window even if dispatch failed — better to
|
||||
// miss one notification than to flood admins on a backend
|
||||
// outage.
|
||||
await prisma.integrationStatus.update({
|
||||
where: { userId_integration: { userId, integration } },
|
||||
data: { alertedAt: now },
|
||||
});
|
||||
|
||||
if (outcome?.channelsAttempted) {
|
||||
// An attempted channel owns its own retry/cooldown policy. Stamp the
|
||||
// alert window even when every attempt failed so a backend outage
|
||||
// cannot turn this failure streak into an hourly notification flood.
|
||||
await prisma.integrationStatus.update({
|
||||
where: { userId_integration: { userId, integration } },
|
||||
data: { alertedAt: now },
|
||||
});
|
||||
} else if (outcome) {
|
||||
// No recipient channel accepted an attempt. Leaving alertedAt empty
|
||||
// keeps the missing delivery visible instead of recording a page that
|
||||
// never left the process.
|
||||
getEvent()?.addWarning(
|
||||
`No admin notification channel available for ${integration} failure alert`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1203,18 +1217,15 @@ export function formatAdminAlertPayload(input: AlertInput): {
|
||||
}
|
||||
|
||||
/**
|
||||
* Page admins on Telegram via the existing dispatcher. We do NOT add
|
||||
* a new sender, channel type, or retry path — B3 owns that surface.
|
||||
* The dispatcher is opt-in per-event and per-channel, so an admin
|
||||
* who has not configured Telegram simply gets no message (silent
|
||||
* fall-through is the documented behaviour).
|
||||
* Page admins through the existing dispatcher. We do NOT add a new sender,
|
||||
* channel type, or retry path. The dispatcher is opt-in per event and channel,
|
||||
* and its outcome is the authority on whether a delivery was attempted.
|
||||
*
|
||||
* The notification is sent to EACH admin user with a Telegram channel
|
||||
* configured. We resolve recipients once per failure burst (gated by
|
||||
* `alertedAt` upstream) so a deployment with a single admin doesn't
|
||||
* pay for a query per user.
|
||||
* The notification is sent to each admin user. The returned outcome aggregates
|
||||
* the dispatcher's real per-admin outcomes so one attempted delivery cannot be
|
||||
* hidden by another admin having no enabled channel.
|
||||
*/
|
||||
async function maybeAlertAdmins(input: AlertInput): Promise<void> {
|
||||
async function maybeAlertAdmins(input: AlertInput): Promise<DispatchOutcome> {
|
||||
const subject = await prisma.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { email: true },
|
||||
@@ -1226,24 +1237,35 @@ async function maybeAlertAdmins(input: AlertInput): Promise<void> {
|
||||
});
|
||||
|
||||
if (admins.length === 0) {
|
||||
getEvent()?.addWarning(
|
||||
`No admin user found to alert about persistent ${input.integration} failure for user ${input.userId}`,
|
||||
);
|
||||
return;
|
||||
return {
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = formatAdminAlertPayload({
|
||||
...input,
|
||||
subjectLabel: subject?.email ?? input.userId,
|
||||
});
|
||||
const outcome: DispatchOutcome = {
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
};
|
||||
|
||||
for (const admin of admins) {
|
||||
await dispatchNotification({
|
||||
const adminOutcome = await dispatchNotification({
|
||||
eventType: "SYSTEM_ALERT",
|
||||
userId: admin.id,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
metadata: payload.metadata,
|
||||
});
|
||||
outcome.dispatched ||= adminOutcome.dispatched;
|
||||
outcome.channelsAttempted += adminOutcome.channelsAttempted;
|
||||
outcome.channelsSucceeded += adminOutcome.channelsSucceeded;
|
||||
}
|
||||
|
||||
return outcome;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
JOB_FAILURE_WINDOW_HOURS,
|
||||
PG_BOSS_FAILED_ROW_AVAILABILITY_HOURS,
|
||||
} from "@/lib/jobs/job-failures";
|
||||
|
||||
const PG_BOSS_ROOT = join(process.cwd(), "node_modules/pg-boss");
|
||||
|
||||
describe("pg-boss failed-row ledger retention", () => {
|
||||
it("pins the installed source assumptions behind the 72-hour reader", () => {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(PG_BOSS_ROOT, "package.json"), "utf8"),
|
||||
) as { version: string };
|
||||
const plans = readFileSync(join(PG_BOSS_ROOT, "dist/plans.js"), "utf8");
|
||||
|
||||
expect(packageJson.version).toBe("12.26.0");
|
||||
expect(plans).toContain("retention_seconds: FORTEEN_DAYS");
|
||||
expect(plans).toContain("deletion_seconds: SEVEN_DAYS");
|
||||
expect(plans).toContain(
|
||||
"completed_on + deletion_seconds * interval '1s' < now()",
|
||||
);
|
||||
expect(JOB_FAILURE_WINDOW_HOURS).toBeLessThan(
|
||||
PG_BOSS_FAILED_ROW_AVAILABILITY_HOURS,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SUBSYSTEM_SURFACE,
|
||||
type SubsystemSurface,
|
||||
} from "@/lib/jobs/subsystem-surface";
|
||||
|
||||
const REGISTRARS = [
|
||||
"register-integration-sync.ts",
|
||||
"register-status.ts",
|
||||
"register-rollup.ts",
|
||||
"register-reminders.ts",
|
||||
"register-maintenance.ts",
|
||||
].map((file) => join(process.cwd(), "src/lib/jobs/reminder", file));
|
||||
|
||||
function withoutComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
export function extractAllQueueBindings(source: string): string[] {
|
||||
const match = /const\s+allQueues\s*=\s*\[([\s\S]*?)\n\s*\];/.exec(source);
|
||||
if (!match) throw new Error("Registrar has no allQueues array");
|
||||
|
||||
return withoutComments(match[1])
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(entry)) {
|
||||
throw new Error(`Unsupported allQueues entry: ${entry}`);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
function literalPattern(binding: string): RegExp {
|
||||
return new RegExp(
|
||||
`(?:export\\s+)?const\\s+${binding}(?:\\s*:[^=;]+)?\\s*=\\s*["']([^"']+)["']`,
|
||||
);
|
||||
}
|
||||
|
||||
function modulePath(fromFile: string, specifier: string): string {
|
||||
const base = specifier.startsWith("@/")
|
||||
? resolve(process.cwd(), "src", specifier.slice(2))
|
||||
: resolve(dirname(fromFile), specifier);
|
||||
const candidates = [`${base}.ts`, `${base}.tsx`, join(base, "index.ts")];
|
||||
const found = candidates.find((candidate) => existsSync(candidate));
|
||||
if (!found) throw new Error(`Cannot resolve ${specifier} from ${fromFile}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function resolveQueueBinding(
|
||||
binding: string,
|
||||
file: string,
|
||||
seen: Set<string> = new Set(),
|
||||
): string {
|
||||
const visit = `${file}:${binding}`;
|
||||
if (seen.has(visit)) throw new Error(`Cyclic queue binding: ${visit}`);
|
||||
seen.add(visit);
|
||||
|
||||
const source = withoutComments(readFileSync(file, "utf8"));
|
||||
const local = literalPattern(binding).exec(source);
|
||||
if (local) return local[1];
|
||||
|
||||
const imports = /import\s*{([\s\S]*?)}\s*from\s*["']([^"']+)["'];?/g;
|
||||
for (const match of source.matchAll(imports)) {
|
||||
for (const rawToken of match[1].split(",")) {
|
||||
const token = rawToken.trim().replace(/^type\s+/, "");
|
||||
if (!token) continue;
|
||||
const [imported, alias] = token
|
||||
.split(/\s+as\s+/)
|
||||
.map((part) => part.trim());
|
||||
if ((alias ?? imported) !== binding) continue;
|
||||
return resolveQueueBinding(imported, modulePath(file, match[2]), seen);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Cannot resolve queue binding ${binding} from ${file}`);
|
||||
}
|
||||
|
||||
function registeredQueueNames(): string[] {
|
||||
return REGISTRARS.flatMap((file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
return extractAllQueueBindings(source).map((binding) =>
|
||||
resolveQueueBinding(binding, file),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function findSurfaceDrift(
|
||||
queueNames: readonly string[],
|
||||
surfaces: Readonly<Record<string, SubsystemSurface>>,
|
||||
): { missing: string[]; extra: string[] } {
|
||||
const registered = new Set(queueNames);
|
||||
const classified = new Set(Object.keys(surfaces));
|
||||
return {
|
||||
missing: [...registered].filter((name) => !classified.has(name)).sort(),
|
||||
extra: [...classified].filter((name) => !registered.has(name)).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function assertSurfaceComplete(
|
||||
queueNames: readonly string[],
|
||||
surfaces: Readonly<Record<string, SubsystemSurface>>,
|
||||
): void {
|
||||
const drift = findSurfaceDrift(queueNames, surfaces);
|
||||
if (drift.missing.length === 0 && drift.extra.length === 0) return;
|
||||
throw new Error(
|
||||
`Subsystem surface drift: missing=[${drift.missing.join(", ")}], extra=[${drift.extra.join(", ")}]`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SUBSYSTEM_SURFACE", () => {
|
||||
it("classifies every current registrar queue with no stale entries", () => {
|
||||
const names = registeredQueueNames();
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(() => assertSurfaceComplete(names, SUBSYSTEM_SURFACE)).not.toThrow();
|
||||
});
|
||||
|
||||
it("requires none reasons to cite a test path or issue", () => {
|
||||
for (const surface of Object.values(SUBSYSTEM_SURFACE)) {
|
||||
if (surface.audience !== "none") continue;
|
||||
expect(surface.reason).toMatch(/^(?:#\d+|.+\.test\.tsx?(?::\d+)?)$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("catches a new allQueues binding without a surface entry", () => {
|
||||
const fixture = `
|
||||
const EXISTING_QUEUE = "existing-queue";
|
||||
const NEW_QUEUE = "new-queue";
|
||||
const allQueues = [
|
||||
EXISTING_QUEUE,
|
||||
NEW_QUEUE,
|
||||
];
|
||||
`;
|
||||
const names = extractAllQueueBindings(fixture).map((binding) => {
|
||||
const match = literalPattern(binding).exec(withoutComments(fixture));
|
||||
if (!match)
|
||||
throw new Error(`Fixture binding did not resolve: ${binding}`);
|
||||
return match[1];
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
assertSurfaceComplete(names, {
|
||||
"existing-queue": { audience: "system" },
|
||||
}),
|
||||
).toThrow(/missing=\[new-queue\]/);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,20 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { redactSecrets } from "@/lib/logging/redact";
|
||||
|
||||
/**
|
||||
* pg-boss 12.26.0 stores terminal rows until
|
||||
* `completed_on + deletion_seconds` (`dist/plans.js`). Its queue default is
|
||||
* 604800 seconds, while `retention_seconds` defaults to 1209600 seconds; job
|
||||
* insertion copies those queue values and maintenance applies the terminal-row
|
||||
* deletion clock. The deployed queue-table audit found those same values on
|
||||
* every queue, and no job submitter overrides either option.
|
||||
*
|
||||
* Seven days is therefore the verified availability floor for failed rows.
|
||||
* The reader stays at 72 hours, safely inside that floor, and pg-boss remains
|
||||
* the single failure ledger.
|
||||
*/
|
||||
export const PG_BOSS_FAILED_ROW_AVAILABILITY_HOURS = 7 * 24;
|
||||
|
||||
/** How far back a failure still counts as news. */
|
||||
export const JOB_FAILURE_WINDOW_HOURS = 72;
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Primary audience for a terminal queue failure. `account` queues already
|
||||
* have an affected-account status surface; `system` queues use the admin
|
||||
* failing-jobs card. An intentional `none` must point to an executable test or
|
||||
* tracked issue so a prose-only exemption cannot hide a new queue.
|
||||
*/
|
||||
export type SubsystemSurface =
|
||||
| { audience: "account"; reason?: never }
|
||||
| { audience: "system"; reason?: never }
|
||||
| {
|
||||
audience: "none";
|
||||
reason:
|
||||
| `#${number}`
|
||||
| `${string}.test.ts${string}`
|
||||
| `${string}.test.tsx${string}`;
|
||||
};
|
||||
|
||||
const subsystemSurface = {
|
||||
// register-integration-sync.ts
|
||||
"withings-fallback-sync": { audience: "account" },
|
||||
"withings-activity-sync": { audience: "account" },
|
||||
"withings-sleep-sync": { audience: "account" },
|
||||
"withings-ecg-sync": { audience: "account" },
|
||||
"withings-oauth-state-cleanup": { audience: "system" },
|
||||
"whoop-recovery-sync": { audience: "account" },
|
||||
"whoop-sleep-sync": { audience: "account" },
|
||||
"whoop-workout-sync": { audience: "account" },
|
||||
"whoop-cycle-sync": { audience: "account" },
|
||||
"whoop-backfill": { audience: "system" },
|
||||
"whoop-oauth-state-cleanup": { audience: "system" },
|
||||
"fitbit-sync": { audience: "account" },
|
||||
"fitbit-backfill": { audience: "system" },
|
||||
"fitbit-sleep-repair": { audience: "system" },
|
||||
"fitbit-oauth-state-cleanup": { audience: "system" },
|
||||
"google-health-sync": { audience: "account" },
|
||||
"google-health-backfill": { audience: "system" },
|
||||
"google-health-sleep-repair": { audience: "system" },
|
||||
"google-health-oauth-state-cleanup": { audience: "system" },
|
||||
"sleep-timeline-backfill": { audience: "system" },
|
||||
"lab-biomarker-backfill": { audience: "system" },
|
||||
"integration-backfill-admission": { audience: "system" },
|
||||
"nightscout-sync": { audience: "account" },
|
||||
"polar-sync": { audience: "account" },
|
||||
"oura-sync": { audience: "account" },
|
||||
"strava-sync": { audience: "account" },
|
||||
"strava-backfill": { audience: "system" },
|
||||
"oidc-native-handoff-cleanup": { audience: "system" },
|
||||
|
||||
// register-status.ts
|
||||
"insights-general-status": { audience: "system" },
|
||||
"insights-blood-pressure-status": { audience: "system" },
|
||||
"insights-weight-status": { audience: "system" },
|
||||
"insights-pulse-status": { audience: "system" },
|
||||
"insights-bmi-status": { audience: "system" },
|
||||
"insights-mood-status": { audience: "system" },
|
||||
"insights-medication-compliance-status": { audience: "system" },
|
||||
"insight-pregenerate": { audience: "system" },
|
||||
"insight-status-generate": { audience: "system" },
|
||||
"morning-digest-refresh": { audience: "system" },
|
||||
"data-arrival": { audience: "system" },
|
||||
"reaction-line-generate": { audience: "system" },
|
||||
"workout-insight-generate": { audience: "system" },
|
||||
"recovery-score-compute": { audience: "system" },
|
||||
"stress-score-compute": { audience: "system" },
|
||||
"strain-score-compute": { audience: "system" },
|
||||
"period-narrative-warm": { audience: "system" },
|
||||
"coach-memory-refresh": { audience: "system" },
|
||||
"coach-nudge": { audience: "system" },
|
||||
"coach-reminder-sweep": { audience: "system" },
|
||||
"coach-plan-review": { audience: "system" },
|
||||
"medication-low-stock": { audience: "system" },
|
||||
"daily-briefing": { audience: "system" },
|
||||
|
||||
// register-rollup.ts
|
||||
"rollup-recompute": { audience: "system" },
|
||||
"rollup-full-backfill": { audience: "system" },
|
||||
"mood-rollup-recompute": { audience: "system" },
|
||||
"mood-rollup-full-backfill": { audience: "system" },
|
||||
"medication-compliance-full-backfill": { audience: "system" },
|
||||
"step-consolidation": { audience: "system" },
|
||||
"step-consolidation-repair": { audience: "system" },
|
||||
"cumulative-pr-rederive": { audience: "system" },
|
||||
"mean-consolidation": { audience: "system" },
|
||||
"dense-intraday-retention": { audience: "system" },
|
||||
"dense-intraday-hourly-rebuild": { audience: "system" },
|
||||
"drain-per-sample-cumulative": { audience: "system" },
|
||||
|
||||
// register-reminders.ts
|
||||
"medication-reminder-check": { audience: "system" },
|
||||
"mood-reminder-check": { audience: "system" },
|
||||
"cycle-reminder-check": { audience: "system" },
|
||||
"measurement-reminder-check": { audience: "system" },
|
||||
"reminder-satisfy": { audience: "system" },
|
||||
|
||||
// register-maintenance.ts
|
||||
"environment-fetch": { audience: "account" },
|
||||
"data-backup": { audience: "system" },
|
||||
"rate-limit-cleanup": { audience: "system" },
|
||||
"idempotency-cleanup": { audience: "system" },
|
||||
"audit-log-cleanup": { audience: "system" },
|
||||
"step-up-elevation-cleanup": { audience: "system" },
|
||||
"data-backup-offhost": { audience: "system" },
|
||||
"data-restore-drill": { audience: "system" },
|
||||
"host-metric-sample": { audience: "system" },
|
||||
"feedback-aggregator": { audience: "system" },
|
||||
"geo-backfill": { audience: "system" },
|
||||
"tls-pin-monitor": { audience: "system" },
|
||||
"pr-detection": { audience: "system" },
|
||||
"medication-inventory-expire": { audience: "system" },
|
||||
"intake-auto-skip": { audience: "system" },
|
||||
"apple-health-import-v2": { audience: "account" },
|
||||
"apple-health-import": { audience: "account" },
|
||||
"apple-health-import-reconcile": { audience: "account" },
|
||||
"medication-intake-import": { audience: "system" },
|
||||
"intake-slot-dedup": { audience: "system" },
|
||||
"mood-reminder-cleanup": { audience: "system" },
|
||||
"push-attempt-cleanup": { audience: "system" },
|
||||
"arrival-reaction-cleanup": { audience: "system" },
|
||||
"measurement-tombstone-cleanup": { audience: "system" },
|
||||
"coach-message-cleanup": { audience: "system" },
|
||||
"note-encryption-backfill": { audience: "system" },
|
||||
"encryption-key-rotate": { audience: "system" },
|
||||
"mcp-token-cleanup": { audience: "system" },
|
||||
"med-notes-encryption-backfill": { audience: "system" },
|
||||
"document-tombstone-purge": { audience: "system" },
|
||||
"document-content-index-backfill": { audience: "account" },
|
||||
"document-index": { audience: "account" },
|
||||
"document-thumbnail": { audience: "account" },
|
||||
"document-thumbnail-backfill": { audience: "account" },
|
||||
"document-summary": { audience: "account" },
|
||||
"document-summary-catchup": { audience: "account" },
|
||||
} as const satisfies Record<string, SubsystemSurface>;
|
||||
|
||||
/** The queue-name union follows the registrar-guarded registry, never a count. */
|
||||
export type QueueName = keyof typeof subsystemSurface;
|
||||
|
||||
export const SUBSYSTEM_SURFACE: Record<QueueName, SubsystemSurface> =
|
||||
subsystemSurface;
|
||||
@@ -35,7 +35,11 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
// B3 owns it. Stub returns void so the helper's await chain
|
||||
// resolves cleanly.
|
||||
vi.mock("@/lib/notifications/dispatcher", () => ({
|
||||
dispatchNotification: vi.fn().mockResolvedValue(undefined),
|
||||
dispatchNotification: vi.fn().mockResolvedValue({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
|
||||
Reference in new issue
Block a user