diff --git a/prisma/migrations/0029_integration_status/migration.sql b/prisma/migrations/0029_integration_status/migration.sql new file mode 100644 index 000000000..512015ea0 --- /dev/null +++ b/prisma/migrations/0029_integration_status/migration.sql @@ -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; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 99e4f2009..044d57cdb 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts new file mode 100644 index 000000000..9b8569bb5 --- /dev/null +++ b/src/app/api/integrations/status/route.ts @@ -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; +} diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx new file mode 100644 index 000000000..c22fdb704 --- /dev/null +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -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( + + + , + ); +} + +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"); + }); +}); diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index da103d320..f4aa30f9b 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -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 ( +
+
+ + {status.state !== "connected" && ( + + {t("settings.integrationStatus.consecutiveFailures", { + count: status.consecutiveFailures, + threshold: threshold ?? 3, + })} + + )} +
+
+ {status.lastSuccessAt && ( +
+
+ {t("settings.integrationStatus.lastSuccess")} +
+
{formatDateTime(status.lastSuccessAt)}
+
+ )} + {status.lastAttemptAt && ( +
+
+ {t("settings.integrationStatus.lastAttempt")} +
+
{formatDateTime(status.lastAttemptAt)}
+
+ )} +
+ {status.lastError && ( +
+ + {status.lastError} +
+ )} +
+ ); +} + +function StatusBadge({ state }: { state: IntegrationState }) { + const { t } = useTranslations(); + switch (state) { + case "connected": + return ( + + {t("settings.integrationStatus.stateConnected")} + + ); + case "error_reauth": + return ( + + {t("settings.integrationStatus.stateReauthRequired")} + + ); + case "error_transient": + return ( + + {t("settings.integrationStatus.stateError")} + + ); + case "disconnected": + return ( + + {t("settings.integrationStatus.stateDisconnected")} + + ); + } +} + 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() {

- - {moodLogEnabled && } + + } + /> + {moodLogEnabled && ( + + } + /> + )} ); } -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(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 }) {

+ {statusBanner}

{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")}

+ {statusBanner} +
diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts new file mode 100644 index 000000000..f624e7ccb --- /dev/null +++ b/src/lib/integrations/__tests__/status.test.ts @@ -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, + }); + }); +}); diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts new file mode 100644 index 000000000..a0028c534 --- /dev/null +++ b/src/lib/integrations/status.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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