feat(integrations): connection-state + sync-error UI in Settings → Integrations

v1.4.15 Phase B2, criterion 1. Until this release the Settings →
Integrations card showed "Connected / Last sync 23 min ago" and
nothing else. A user whose Withings refresh token had revoked saw a
green badge; a user whose moodLog instance was down saw the same
green badge with a slightly older timestamp. The signal was useless.

This commit adds:

  - New `IntegrationStatus` table (`integration_statuses` migration
    `0029`). One row per (user, integration) capturing `state`
    (`connected | error_transient | error_reauth | disconnected`),
    `lastSuccessAt`, `lastAttemptAt`, encrypted `lastError`,
    `consecutiveFailures` counter, `alertedAt` window-guard.
  - `src/lib/integrations/status.ts` — the only writers
    (`recordSyncSuccess`, `recordSyncFailure`, `markReauthRequired`,
    `markReconnected`, `markDisconnected`) plus a read-only
    `getIntegrationStatus()` for the API. The error column is
    AES-256-GCM encrypted at rest because Withings/moodLog 401s can
    echo URL/apiKey fragments — we can't ship that into a future
    backup tarball.
  - `GET /api/integrations/status` — single roundtrip returns both
    Withings and moodLog snapshots plus the global threshold (so the
    UI's "{n}/{threshold}" string is server-sourced and rotates with
    the env var).
  - Settings → Integrations cards both gain an
    `IntegrationStatusBanner` row above the credentials form: state
    badge ("Connected" / "Reconnect required" / "Error" /
    "Disconnected"), `{n}/{threshold} consecutive failures` chip,
    last-success / last-attempt times, and a destructive-toned
    last-error line. The banner is suppressed entirely when the
    integration is fresh-connected with no history (no false alarms).

Tests: +20 unit (status helper threshold + alert-window state machine,
banner rendering across all four states) + 8 integration
(`tests/integration/integration-status.test.ts`) confirming
encrypted-at-rest, audit-log row shape, alert idempotency window,
and CASCADE-delete on User. 116 files / 883 unit pass, 11 integration
files / 39 pass.

Encryption uses the existing `src/lib/crypto.ts` AES-256-GCM helper —
no new key material. The `consecutive_failures` counter and
`alerted_at` window are wired to the dispatcher in the next commit
(`feat(integrations): admin Telegram alert on persistent sync
failure`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:04:04 +02:00
1 parent 7c32d6344d
commit 4db72a8c2e
8 files changed
+1382 -5

No files matched your search

@@ -0,0 +1,52 @@
-- v1.4.15 Phase B2: per-(user, integration) sync status row.
--
-- The Settings → Integrations card needs more than a "connected yes/no"
-- bit to be useful: it must show last-success time, last-attempt time,
-- the most-recent error, and a "needs reconnect" state when the
-- refresh-token grant has revoked. A separate table is preferable to
-- piling more columns onto `users` / `withings_connections` because:
--
-- 1. moodLog credentials live on `users`, Withings tokens live on
-- `withings_connections` — neither is a good home for the OTHER
-- integration's status.
-- 2. Future integrations (HealthKit, Garmin, etc.) get a row here
-- without a schema migration per integration.
-- 3. The (userId, integration) UNIQUE constraint upserts cleanly
-- from the `recordSyncSuccess` / `recordSyncFailure` helpers in
-- `src/lib/integrations/status.ts`.
--
-- Encryption: `last_error` is AES-256-GCM encrypted at the application
-- layer (see `src/lib/crypto.ts`) because some upstream errors echo
-- back fragments of the user's URL or apiKey (moodLog 401 responses
-- can include the supplied Authorization header in the body). Keeping
-- this column ciphertext-only matches the Withings/moodLog credential
-- treatment elsewhere in the schema.
--
-- `consecutive_failures` resets to 0 on the next success and feeds the
-- admin-Telegram alert threshold (default 3, see PERSISTENT_FAILURE_*
-- constants in `src/lib/integrations/status.ts`). `alerted_at` is the
-- idempotency guard so the alert fires once per failure burst, not
-- once per failure.
CREATE TABLE "integration_statuses" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"integration" TEXT NOT NULL,
"state" TEXT NOT NULL DEFAULT 'connected',
"last_success_at" TIMESTAMP(3),
"last_attempt_at" TIMESTAMP(3),
"last_error" TEXT,
"consecutive_failures" INTEGER NOT NULL DEFAULT 0,
"alerted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "integration_statuses_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "integration_statuses_user_id_idx" ON "integration_statuses"("user_id");
CREATE INDEX "integration_statuses_state_idx" ON "integration_statuses"("state");
CREATE UNIQUE INDEX "integration_statuses_user_id_integration_key" ON "integration_statuses"("user_id", "integration");
ALTER TABLE "integration_statuses" ADD CONSTRAINT "integration_statuses_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+1 -1
View File
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+111
View File
@@ -0,0 +1,111 @@
/**
* Combined status endpoint for the Settings → Integrations card.
*
* Returns a snapshot per integration containing:
* - the IntegrationStatus row (state, last-success, last-attempt,
* decrypted last-error, consecutive failures, threshold)
* - integration-specific extras the UI already shows (Withings:
* credentials configured, token expiry, OAuth-connected; moodLog:
* credentials configured, enabled flag)
*
* Combining both into one fetch removes a third round-trip from the
* Settings page. The legacy /api/withings/status and
* /api/integrations/moodlog/status remain for the existing UI/tests
* — we add this in addition rather than rewriting them.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
getIntegrationStatus,
getPersistentFailureThreshold,
type IntegrationKey,
} from "@/lib/integrations/status";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.status" } });
const [withingsStatus, moodLogStatus, dbUser, withingsConn] =
await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
prisma.user.findUnique({
where: { id: user.id },
select: {
withingsClientIdEncrypted: true,
withingsClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
moodLogLastSyncedAt: true,
},
}),
prisma.withingsConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
]);
const now = Date.now();
return apiSuccess({
threshold: getPersistentFailureThreshold(),
integrations: [
{
...withingsStatus,
configured:
!!dbUser?.withingsClientIdEncrypted &&
!!dbUser?.withingsClientSecretEncrypted,
connected: !!withingsConn,
connectedAt: withingsConn?.createdAt?.toISOString() ?? null,
// Surface the connection row's success time too so the UI
// can fall back to it when no IntegrationStatus row exists
// yet (legacy connections established before this feature).
legacyLastSyncedAt: withingsConn?.lastSyncedAt?.toISOString() ?? null,
tokenExpiresAt: withingsConn?.tokenExpiresAt?.toISOString() ?? null,
tokenExpired: withingsConn
? withingsConn.tokenExpiresAt.getTime() <= now
: null,
} satisfies IntegrationViewModel & WithingsExtras,
{
...moodLogStatus,
configured:
!!dbUser?.moodLogUrlEncrypted && !!dbUser?.moodLogApiKeyEncrypted,
enabled: dbUser?.moodLogEnabled ?? false,
legacyLastSyncedAt: dbUser?.moodLogLastSyncedAt?.toISOString() ?? null,
} satisfies IntegrationViewModel & MoodLogExtras,
],
});
});
interface IntegrationViewModel {
integration: IntegrationKey;
state: string;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
consecutiveFailures: number;
}
interface WithingsExtras {
configured: boolean;
connected: boolean;
connectedAt: string | null;
legacyLastSyncedAt: string | null;
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
}
interface MoodLogExtras {
configured: boolean;
enabled: boolean;
legacyLastSyncedAt: string | null;
}
@@ -0,0 +1,209 @@
/**
* v1.4.15 Phase B2 — Settings → Integrations status surface tests.
*
* The four states this file locks in (one assertion each):
* 1. connected → no banner, "Connected" badge only
* 2. error_transient → banner with "{count}/{threshold}"
* counter, last-error message visible
* 3. error_reauth → "Reconnect required" badge surfaces
* 4. disconnected → "Disconnected" badge surfaces (used
* after the user clicks "Disconnect")
*
* We test by rendering only the IntegrationsSection with a TanStack
* Query mock that returns the desired status. The other queries
* (withings status, moodlog status, global services) are stubbed to
* stable defaults — the test isn't about those surfaces.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/integrations",
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER" },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
// Per-test mock — we'll rewrite the integration-status payload between
// cases via `setIntegrationStatus()`. Withings + moodlog status return
// stable defaults so the cards render their connect-CTA branch (the
// banner is what we actually assert on).
let integrationStatusPayload: unknown = null;
function setIntegrationStatus(payload: unknown) {
integrationStatusPayload = payload;
}
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
const key = Array.isArray(queryKey) ? queryKey.join("/") : "";
if (key === "integrations/status") {
return { data: integrationStatusPayload, isLoading: false };
}
if (key === "settings/global-services") {
return {
data: {
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
moodLogGlobal: true,
},
isLoading: false,
};
}
if (key === "withings/status") {
return {
data: { connected: false, configured: false },
isLoading: false,
};
}
if (key === "moodlog-status") {
return {
data: {
configured: false,
enabled: false,
lastSyncedAt: null,
entryCount: 0,
webhookSecret: null,
},
isLoading: false,
refetch: vi.fn(),
};
}
return { data: null, isLoading: false, refetch: vi.fn() };
},
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { IntegrationsSection } from "../integrations-section";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<IntegrationsSection />
</I18nProvider>,
);
}
describe("IntegrationsSection — status surface", () => {
beforeEach(() => {
integrationStatusPayload = null;
});
it("renders no error banner when both integrations are healthy", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
consecutiveFailures: 0,
},
{
integration: "moodlog",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
consecutiveFailures: 0,
},
],
});
const html = render();
expect(html).not.toContain("Reconnect required");
expect(html).not.toContain("integration-status-error");
});
it("surfaces an error banner with last-error and {n}/{threshold} when transient failure", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_transient",
lastSuccessAt: "2026-05-09T08:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings refresh error: 503 - upstream",
consecutiveFailures: 2,
},
{
integration: "moodlog",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
consecutiveFailures: 0,
},
],
});
const html = render();
expect(html).toContain("2/3 consecutive failures");
expect(html).toContain("Withings refresh error: 503 - upstream");
expect(html).toContain("integration-status-banner");
});
it("surfaces 'Reconnect required' when a refresh-token grant has revoked", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_reauth",
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings refresh error: 100 - invalid_grant",
consecutiveFailures: 1,
},
{
integration: "moodlog",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
consecutiveFailures: 0,
},
],
});
const html = render();
expect(html).toContain("Reconnect required");
});
it("renders the disconnected tombstone state without error styling", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
consecutiveFailures: 0,
},
{
integration: "moodlog",
state: "disconnected",
lastSuccessAt: "2026-05-08T10:00:00.000Z",
lastAttemptAt: "2026-05-08T10:00:00.000Z",
lastError: null,
consecutiveFailures: 0,
},
],
});
const html = render();
expect(html).toContain("Disconnected");
expect(html).not.toContain("Reconnect required");
});
});
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
Download,
Link2,
Loader2,
@@ -50,6 +51,162 @@ interface GlobalServiceAvailability {
moodLogGlobal: boolean;
}
// v1.4.15 Phase B2: shared status payload for both integration cards.
// Mirrors the `IntegrationViewModel` shape returned by
// `GET /api/integrations/status` (single roundtrip, two integrations).
type IntegrationKey = "withings" | "moodlog";
type IntegrationState =
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected";
interface IntegrationStatusViewModel {
integration: IntegrationKey;
state: IntegrationState;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
consecutiveFailures: number;
configured?: boolean;
connected?: boolean;
connectedAt?: string | null;
legacyLastSyncedAt?: string | null;
tokenExpiresAt?: string | null;
tokenExpired?: boolean | null;
enabled?: boolean;
}
interface IntegrationStatusEnvelope {
threshold: number;
integrations: IntegrationStatusViewModel[];
}
/**
* Shared status fetch for the Settings → Integrations card. Returns
* the per-integration view-model AND the global threshold so the
* "{n}/{threshold} consecutive failures" string in the UI is single-
* sourced from the server.
*/
function useIntegrationStatuses(enabled: boolean) {
return useQuery({
queryKey: ["integrations", "status"],
queryFn: async () => {
const res = await fetch("/api/integrations/status");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as IntegrationStatusEnvelope;
},
enabled,
// The status endpoint is light (one DB read per integration) and
// the user typically lands here right after a sync — refetching
// on focus avoids a stale "last sync 5 min ago" reading.
refetchOnWindowFocus: true,
});
}
function pickStatus(
envelope: IntegrationStatusEnvelope | undefined,
integration: IntegrationKey,
): IntegrationStatusViewModel | undefined {
return envelope?.integrations.find((i) => i.integration === integration);
}
/**
* Connection-state badge + "X consecutive failures" + last-error
* surface. Renders nothing when the integration has no history yet
* AND is connected — there's nothing useful to show in that case.
*/
function IntegrationStatusBanner({
status,
threshold,
}: {
status: IntegrationStatusViewModel | undefined;
threshold: number | undefined;
}) {
const { t } = useTranslations();
if (!status) return null;
const showBanner =
status.state !== "connected" ||
status.consecutiveFailures > 0 ||
!!status.lastError ||
!!status.lastAttemptAt;
if (!showBanner) return null;
return (
<div
data-testid="integration-status-banner"
className="bg-muted/40 border-border space-y-2 rounded-md border p-3 text-xs"
>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge state={status.state} />
{status.state !== "connected" && (
<Badge variant="outline" className="text-xs">
{t("settings.integrationStatus.consecutiveFailures", {
count: status.consecutiveFailures,
threshold: threshold ?? 3,
})}
</Badge>
)}
</div>
<dl className="text-muted-foreground grid gap-1 sm:grid-cols-2">
{status.lastSuccessAt && (
<div>
<dt className="font-medium">
{t("settings.integrationStatus.lastSuccess")}
</dt>
<dd>{formatDateTime(status.lastSuccessAt)}</dd>
</div>
)}
{status.lastAttemptAt && (
<div>
<dt className="font-medium">
{t("settings.integrationStatus.lastAttempt")}
</dt>
<dd>{formatDateTime(status.lastAttemptAt)}</dd>
</div>
)}
</dl>
{status.lastError && (
<div className="text-destructive flex items-start gap-1.5">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span data-testid="integration-status-error">{status.lastError}</span>
</div>
)}
</div>
);
}
function StatusBadge({ state }: { state: IntegrationState }) {
const { t } = useTranslations();
switch (state) {
case "connected":
return (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green text-xs">
{t("settings.integrationStatus.stateConnected")}
</Badge>
);
case "error_reauth":
return (
<Badge variant="destructive" className="text-xs">
{t("settings.integrationStatus.stateReauthRequired")}
</Badge>
);
case "error_transient":
return (
<Badge variant="destructive" className="text-xs">
{t("settings.integrationStatus.stateError")}
</Badge>
);
case "disconnected":
return (
<Badge variant="outline" className="text-xs">
{t("settings.integrationStatus.stateDisconnected")}
</Badge>
);
}
}
export function IntegrationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
@@ -64,6 +221,8 @@ export function IntegrationsSection() {
enabled: isAuthenticated,
});
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
const moodLogEnabled = globalServices?.moodLogGlobal ?? true;
return (
@@ -83,13 +242,36 @@ export function IntegrationsSection() {
</p>
</header>
<WithingsCard isAuthenticated={isAuthenticated} />
{moodLogEnabled && <MoodLogCard />}
<WithingsCard
isAuthenticated={isAuthenticated}
statusBanner={
<IntegrationStatusBanner
status={pickStatus(integrationStatus, "withings")}
threshold={integrationStatus?.threshold}
/>
}
/>
{moodLogEnabled && (
<MoodLogCard
statusBanner={
<IntegrationStatusBanner
status={pickStatus(integrationStatus, "moodlog")}
threshold={integrationStatus?.threshold}
/>
}
/>
)}
</section>
);
}
function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
function WithingsCard({
isAuthenticated,
statusBanner,
}: {
isAuthenticated: boolean;
statusBanner: React.ReactNode;
}) {
const { t } = useTranslations();
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
@@ -129,6 +311,7 @@ function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["withings"] });
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
},
});
@@ -153,6 +336,7 @@ function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
);
setSyncMsgType("success");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
} else {
setSyncMsg(json.error || t("settings.withingsSyncFailed"));
setSyncMsgType("error");
@@ -239,6 +423,7 @@ function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</p>
<div className="mt-4 space-y-4">
{statusBanner}
<div className="space-y-3">
<h3 className="text-sm font-medium">
{t("settings.withingsCredentials")}
@@ -417,8 +602,9 @@ function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
);
}
function MoodLogCard() {
function MoodLogCard({ statusBanner }: { statusBanner: React.ReactNode }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [saving, setSaving] = useState(false);
@@ -450,6 +636,7 @@ function MoodLogCard() {
setUrl("");
setApiKey("");
await refetchStatus();
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
} else {
const json = await res.json();
setMsg(json.error || t("settings.savingError"));
@@ -477,6 +664,7 @@ function MoodLogCard() {
);
setMsgType("success");
await refetchStatus();
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
} else {
setMsg(t("settings.moodLogSyncFailed"));
setMsgType("error");
@@ -494,6 +682,7 @@ function MoodLogCard() {
setMsg(t("settings.moodLogDisconnected"));
setMsgType("success");
await refetchStatus();
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
}
}
@@ -525,6 +714,8 @@ function MoodLogCard() {
{t("settings.moodLogDescription")}
</p>
{statusBanner}
<form onSubmit={handleSave} className="space-y-3">
<div>
<Label>{t("settings.moodLogUrl")}</Label>
@@ -0,0 +1,356 @@
/**
* v1.4.15 Phase B2 — IntegrationStatus helper tests.
*
* The persistence path is exercised by the integration suite under
* `tests/integration/integration-status.test.ts` (real Postgres so the
* upsert + concurrent-update story holds). This file is the unit-level
* suite for the threshold + alerting state machine — Prisma is mocked so
* we control every input and observe the exact sequence of calls.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
integrationStatus: {
findUnique: vi.fn(),
upsert: vi.fn(),
update: vi.fn(),
},
user: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/notifications/dispatcher", () => ({
dispatchNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/crypto", () => ({
// Deterministic encrypt/decrypt for assertion stability.
encrypt: (s: string) => `enc(${s})`,
decrypt: (s: string) =>
s.startsWith("enc(") && s.endsWith(")") ? s.slice(4, -1) : s,
}));
import {
recordSyncFailure,
recordSyncSuccess,
markReauthRequired,
markDisconnected,
markReconnected,
isReauthRequired,
getPersistentFailureThreshold,
} from "../status";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
beforeEach(() => {
vi.resetAllMocks();
delete process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD;
});
describe("getPersistentFailureThreshold", () => {
it("defaults to 3 when env var is unset", () => {
expect(getPersistentFailureThreshold()).toBe(3);
});
it("respects INTEGRATION_FAILURE_ALERT_THRESHOLD when set to a positive integer", () => {
process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "5";
expect(getPersistentFailureThreshold()).toBe(5);
});
it("falls back to 3 when env var is non-numeric", () => {
process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "many";
expect(getPersistentFailureThreshold()).toBe(3);
});
it("falls back to 3 when env var is zero or negative", () => {
process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "0";
expect(getPersistentFailureThreshold()).toBe(3);
process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "-2";
expect(getPersistentFailureThreshold()).toBe(3);
});
});
describe("recordSyncSuccess", () => {
it("upserts the row with state=connected, resets streak, clears alertedAt", async () => {
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
await recordSyncSuccess("u1", "withings");
const args = vi.mocked(prisma.integrationStatus.upsert).mock.calls[0][0];
expect(args.where).toEqual({
userId_integration: { userId: "u1", integration: "withings" },
});
expect(args.update).toEqual({
state: "connected",
lastSuccessAt: expect.any(Date),
lastAttemptAt: expect.any(Date),
lastError: null,
consecutiveFailures: 0,
alertedAt: null,
});
});
});
describe("recordSyncFailure — under threshold", () => {
it("increments the counter and does NOT dispatch an admin alert", async () => {
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 1,
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings refresh error: 503 - upstream",
});
expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce();
expect(auditLog).toHaveBeenCalledWith(
"integrations.sync.failed",
expect.objectContaining({
userId: "u1",
details: expect.objectContaining({
integration: "withings",
attemptNumber: 1,
state: "error_transient",
}),
}),
);
expect(dispatchNotification).not.toHaveBeenCalled();
});
});
describe("recordSyncFailure — at threshold", () => {
it("dispatches an admin Telegram alert when failure count reaches default threshold (3)", async () => {
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 3,
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" },
{ id: "admin-2" },
] as never);
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(dispatchNotification).toHaveBeenCalledTimes(2);
const firstCallPayload = vi.mocked(dispatchNotification).mock.calls[0][0];
expect(firstCallPayload.eventType).toBe("SYSTEM_ALERT");
expect(firstCallPayload.userId).toBe("admin-1");
expect(firstCallPayload.title).toContain(
"Withings sync failing for user@example.com",
);
expect(firstCallPayload.message).toContain("3 times in a row");
expect(firstCallPayload.message).toContain("503");
expect(firstCallPayload.metadata).toEqual({
integration: "withings",
affectedUserId: "u1",
consecutiveFailures: 3,
errorCode: "503",
});
// alertedAt is stamped to gate re-alerting for 24h.
expect(prisma.integrationStatus.update).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId_integration: { userId: "u1", integration: "withings" },
},
data: expect.objectContaining({ 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
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 4,
alertedAt: null,
} as never);
await recordSyncFailure({
userId: "u1",
integration: "moodlog",
kind: "transient",
message: "moodLog sync HTTP 502",
});
expect(dispatchNotification).not.toHaveBeenCalled();
});
});
describe("recordSyncFailure — alert window", () => {
it("does NOT dispatch a second alert within 24h on the same streak", async () => {
const now = Date.now();
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 4,
alertedAt: new Date(now - 60 * 60 * 1000), // 1h ago
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings refresh error: 503 - upstream",
});
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("DOES dispatch a fresh alert after 24h have elapsed", async () => {
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 6,
alertedAt: new Date(Date.now() - 25 * 60 * 60 * 1000), // 25h ago
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
email: "u@example.com",
} as never);
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([
{ id: "admin-1" },
] as never);
vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce(
{} as never,
);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings refresh error: 503 - upstream",
});
expect(dispatchNotification).toHaveBeenCalledOnce();
});
});
describe("recordSyncFailure — reauth_required", () => {
it("marks state=error_reauth and audits with kind=reauth_required", async () => {
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 1,
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "reauth_required",
message: "Withings refresh error: 100 - invalid_grant",
errorCode: "100",
});
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
expect(upsertArgs.update).toMatchObject({ state: "error_reauth" });
expect(auditLog).toHaveBeenCalledWith(
"integrations.sync.failed",
expect.objectContaining({
details: expect.objectContaining({
kind: "reauth_required",
errorCode: "100",
}),
}),
);
});
});
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 () => {
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
consecutiveFailures: 3,
alertedAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
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({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings refresh error: 503 - upstream",
}),
).resolves.toBeUndefined();
expect(dispatchNotification).not.toHaveBeenCalled();
});
});
describe("isReauthRequired", () => {
it("returns true only when state === error_reauth", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_reauth",
} as never);
expect(await isReauthRequired("u1", "withings")).toBe(true);
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_transient",
} as never);
expect(await isReauthRequired("u1", "withings")).toBe(false);
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce(null);
expect(await isReauthRequired("u1", "withings")).toBe(false);
});
});
describe("markReauthRequired / markDisconnected / markReconnected", () => {
it("markReauthRequired: writes encrypted error + audit entry", async () => {
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
await markReauthRequired("u1", "withings", "invalid grant");
expect(prisma.integrationStatus.upsert).toHaveBeenCalled();
expect(auditLog).toHaveBeenCalledWith(
"integrations.reauth_required",
expect.objectContaining({ userId: "u1" }),
);
});
it("markDisconnected: state=disconnected, clears errors & alertedAt", async () => {
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
await markDisconnected("u1", "moodlog");
const args = vi.mocked(prisma.integrationStatus.upsert).mock.calls[0][0];
expect(args.update).toEqual({
state: "disconnected",
lastError: null,
consecutiveFailures: 0,
alertedAt: null,
});
});
it("markReconnected: state=connected, clears prior reauth state", async () => {
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
await markReconnected("u1", "withings");
const args = vi.mocked(prisma.integrationStatus.upsert).mock.calls[0][0];
expect(args.update).toEqual({
state: "connected",
lastError: null,
consecutiveFailures: 0,
alertedAt: null,
});
});
});
+457
View File
@@ -0,0 +1,457 @@
/**
* Per-(user, integration) sync-status bookkeeping.
*
* v1.4.15 Phase B2 introduces three responsibilities for every Withings /
* moodLog sync attempt:
*
* 1. Record success / failure timestamps so Settings → Integrations
* can show "last sync 23 minutes ago" instead of guessing from
* `lastSyncedAt` (which only updates on success and tells the user
* nothing about a 2-day failure streak).
* 2. Emit one structured `AuditLog` row per failure — successes are
* not audited (would be noisy and `lastSuccessAt` already tracks
* them).
* 3. After N consecutive failures (default 3), notify the admin via
* Telegram so a token-revoke or upstream outage doesn't silently
* strand a user's data. The dispatcher is reused as-is — B3 owns
* reliability/retry of the dispatcher; we are only a caller.
*
* The state machine is deliberately small:
*
* connected → happy path. cleared by recordSyncSuccess().
* error_transient → at least one failure since last success. The
* sync entry-point still attempts on the next
* run (network blip, 5xx, etc.).
* error_reauth → refresh-token grant has revoked. `getValidToken`
* / `syncMoodLogEntries` short-circuit until the
* user reconnects. Cleared by markReconnected()
* on the OAuth callback.
* disconnected → user clicked "Disconnect". Set explicitly by
* the disconnect routes; clears any prior error
* state.
*
* `state` is stored as a free-form string column (not a Postgres enum)
* so adding new sentinels in v1.5 doesn't require a migration.
*/
import { prisma } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
import { getEvent } from "@/lib/logging/context";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
export type IntegrationKey = "withings" | "moodlog";
export type IntegrationState =
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected";
/**
* The ladder at which a streak of failures escalates from "user-visible
* banner" to "admin-paged on Telegram". 3 is small enough to catch a
* truly broken integration before the user notices missing data, large
* enough that one network blip doesn't page anyone.
*
* Override via env `INTEGRATION_FAILURE_ALERT_THRESHOLD` for ops who
* want a louder or quieter signal — we read it lazily so tests can
* mutate it per case.
*/
export function getPersistentFailureThreshold(): number {
const raw = process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD;
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
if (Number.isFinite(parsed) && parsed >= 1) return parsed;
return 3;
}
/**
* Re-alert window: once we've paged on a streak we hold the alert for
* 24h before paging again on the same streak (idempotency). The streak
* is implicitly "reset" by a single success, which clears
* consecutiveFailures and alertedAt — so a flapping integration that
* succeeds once an hour will not page repeatedly.
*/
const ALERT_REPEAT_WINDOW_MS = 24 * 60 * 60 * 1000;
export interface IntegrationStatusSnapshot {
integration: IntegrationKey;
state: IntegrationState;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
consecutiveFailures: number;
}
/**
* Read the current snapshot. Returns a synthetic "connected, never
* attempted" record when no row exists yet — the UI treats this as
* "no sync history" without a special case.
*/
export async function getIntegrationStatus(
userId: string,
integration: IntegrationKey,
): Promise<IntegrationStatusSnapshot> {
const row = await prisma.integrationStatus.findUnique({
where: { userId_integration: { userId, integration } },
});
if (!row) {
return {
integration,
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
consecutiveFailures: 0,
};
}
return {
integration,
state: row.state as IntegrationState,
lastSuccessAt: row.lastSuccessAt?.toISOString() ?? null,
lastAttemptAt: row.lastAttemptAt?.toISOString() ?? null,
lastError: row.lastError ? safeDecryptError(row.lastError) : null,
consecutiveFailures: row.consecutiveFailures,
};
}
/**
* Read the current `state` cheaply — used by the sync entry-points
* to short-circuit when reauth is required.
*/
export async function isReauthRequired(
userId: string,
integration: IntegrationKey,
): Promise<boolean> {
const row = await prisma.integrationStatus.findUnique({
where: { userId_integration: { userId, integration } },
select: { state: true },
});
return row?.state === "error_reauth";
}
/**
* Record a successful sync. Resets the failure counter, clears any
* prior error message, and (importantly) flips state back to
* `connected` even from `error_reauth` — the moodLog flow re-enters
* after the user re-supplies the apiKey, the Withings flow after the
* OAuth callback writes a new refresh token.
*/
export async function recordSyncSuccess(
userId: string,
integration: IntegrationKey,
): Promise<void> {
const now = new Date();
await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
userId,
integration,
state: "connected",
lastSuccessAt: now,
lastAttemptAt: now,
consecutiveFailures: 0,
},
update: {
state: "connected",
lastSuccessAt: now,
lastAttemptAt: now,
lastError: null,
consecutiveFailures: 0,
alertedAt: null,
},
});
}
export type FailureKind = "transient" | "reauth_required";
export interface RecordSyncFailureInput {
userId: string;
integration: IntegrationKey;
kind: FailureKind;
message: string;
/** Optional structured error code (e.g. "invalid_grant", "401"). */
errorCode?: string;
}
/**
* Record a sync failure. Always:
* - increments `consecutive_failures`
* - persists the encrypted error message
* - writes one `AuditLog` row with `integrations.sync.failed`
*
* If `kind === "reauth_required"` the row is parked at `error_reauth`
* so the next scheduled sync skips. Otherwise the state is marked
* `error_transient` and the next sync will try again.
*
* If the post-update `consecutive_failures` count crosses the alerting
* threshold AND the alerting window has lapsed, dispatch a Telegram
* notification to all admins. Failures here are best-effort: a failed
* dispatch DOES NOT swallow the audit log.
*/
export async function recordSyncFailure(
input: RecordSyncFailureInput,
): Promise<void> {
const { userId, integration, kind, message, errorCode } = input;
const now = new Date();
const newState: IntegrationState =
kind === "reauth_required" ? "error_reauth" : "error_transient";
// Encrypt the error before persisting. crypto.encrypt() handles all
// edge cases (empty string, etc.) — we just have to give it a string.
const encryptedError = safeEncryptError(message);
const row = await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
userId,
integration,
state: newState,
lastAttemptAt: now,
lastError: encryptedError,
consecutiveFailures: 1,
},
update: {
state: newState,
lastAttemptAt: now,
lastError: encryptedError,
consecutiveFailures: { increment: 1 },
},
});
// Audit log entry — fire-and-await so an integration test can assert
// it. The auth/audit helper is its own DB write so it's safe to call
// serially without bloating latency in the success path (which never
// calls this).
await auditLog("integrations.sync.failed", {
userId,
details: {
integration,
kind,
errorCode: errorCode ?? null,
message,
attemptNumber: row.consecutiveFailures,
state: newState,
},
});
// Persistent-failure alerting. Only trip when:
// 1. We're at or above the threshold AFTER this failure.
// 2. We haven't paged on this streak in the last 24h.
//
// Both conditions matter: (1) prevents premature paging, (2)
// prevents loops where a flapping integration that fails once an
// hour pages every hour.
const threshold = getPersistentFailureThreshold();
if (row.consecutiveFailures >= threshold) {
const previouslyAlerted =
row.alertedAt &&
now.getTime() - row.alertedAt.getTime() < ALERT_REPEAT_WINDOW_MS;
if (!previouslyAlerted) {
await maybeAlertAdmins({
userId,
integration,
kind,
message,
errorCode,
consecutiveFailures: row.consecutiveFailures,
}).catch((err) => {
getEvent()?.addWarning(
`Admin alert dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
// 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 },
});
}
}
}
/**
* Mark a connection as needing re-auth without recording a fresh
* "attempt". Used by the OAuth/refresh-token flows when they detect
* an `invalid_grant`-style permanent revocation OUTSIDE of a sync —
* e.g. the status endpoint that proactively refreshes tokens.
*/
export async function markReauthRequired(
userId: string,
integration: IntegrationKey,
message: string,
): Promise<void> {
await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
userId,
integration,
state: "error_reauth",
lastError: safeEncryptError(message),
consecutiveFailures: 1,
lastAttemptAt: new Date(),
},
update: {
state: "error_reauth",
lastError: safeEncryptError(message),
},
});
await auditLog("integrations.reauth_required", {
userId,
details: { integration, message },
});
}
/**
* Reset the row when the user disconnects. We keep the row (so the UI
* can still show a tombstone "disconnected at <time>") but clear the
* error state.
*/
export async function markDisconnected(
userId: string,
integration: IntegrationKey,
): Promise<void> {
await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
userId,
integration,
state: "disconnected",
},
update: {
state: "disconnected",
lastError: null,
consecutiveFailures: 0,
alertedAt: null,
},
});
}
/**
* Inverse of markReauthRequired — used when the user successfully
* re-completes the OAuth flow. We don't record a fresh sync (no work
* was done) — just reset the streak so the next sync can run.
*/
export async function markReconnected(
userId: string,
integration: IntegrationKey,
): Promise<void> {
await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
userId,
integration,
state: "connected",
},
update: {
state: "connected",
lastError: null,
consecutiveFailures: 0,
alertedAt: null,
},
});
}
// ── helpers ────────────────────────────────────────────────────────
/**
* Encrypt with a fallback that swallows crypto-config errors so a
* misconfigured ENCRYPTION_KEY can never break the audit/error path.
* Worst case we store ciphertext with the literal string `"<encrypt
* failed>"` — the row is still useful (state, attempt, counter) and
* the underlying crash gets a Wide-Event warning.
*/
function safeEncryptError(message: string): string {
try {
return encrypt(message.slice(0, 1024));
} catch (err) {
getEvent()?.addWarning(
`IntegrationStatus error-encrypt failed: ${err instanceof Error ? err.message : String(err)}`,
);
return "<encrypt failed>";
}
}
function safeDecryptError(ciphertext: string): string {
if (ciphertext === "<encrypt failed>") return "(error message unavailable)";
try {
return decrypt(ciphertext);
} catch {
return "(error message unavailable)";
}
}
interface AlertInput {
userId: string;
integration: IntegrationKey;
kind: FailureKind;
message: string;
errorCode: string | undefined;
consecutiveFailures: number;
}
/**
* 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).
*
* 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.
*/
async function maybeAlertAdmins(input: AlertInput): Promise<void> {
const subject = await prisma.user.findUnique({
where: { id: input.userId },
select: { email: true },
});
const admins = await prisma.user.findMany({
where: { role: "ADMIN" },
select: { id: true },
});
if (admins.length === 0) {
getEvent()?.addWarning(
`No admin user found to alert about persistent ${input.integration} failure for user ${input.userId}`,
);
return;
}
const integrationLabel =
input.integration === "withings" ? "Withings" : "moodLog";
const subjectLabel = subject?.email ?? input.userId;
const reasonLabel =
input.kind === "reauth_required" ? "re-auth required" : "transient error";
const codeLabel = input.errorCode ? ` (${input.errorCode})` : "";
// Trim message — Telegram caps at 4096 chars and we don't want to
// drown the admin in stack traces.
const trimmed =
input.message.length > 280
? `${input.message.slice(0, 277)}...`
: input.message;
const title = `${integrationLabel} sync failing for ${subjectLabel}`;
const message =
`${integrationLabel} sync has failed ${input.consecutiveFailures} times in a row for ${subjectLabel}.\n` +
`Last error: ${reasonLabel}${codeLabel} — ${trimmed}\n` +
`Action: ${input.kind === "reauth_required" ? "ask the user to reconnect the integration." : "investigate the upstream service."}`;
for (const admin of admins) {
await dispatchNotification({
eventType: "SYSTEM_ALERT",
userId: admin.id,
title,
message,
metadata: {
integration: input.integration,
affectedUserId: input.userId,
consecutiveFailures: input.consecutiveFailures,
errorCode: input.errorCode ?? null,
},
});
}
}
+1
View File
@@ -44,6 +44,7 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
"devices",
"feedback",
"idempotency_keys",
"integration_statuses",
"measurements",
"medication_intake_events",
"medication_schedules",