mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# 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"
|
provider = "postgresql"
|
||||||
@@ -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 { useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
AlertCircle,
|
||||||
Download,
|
Download,
|
||||||
Link2,
|
Link2,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -50,6 +51,162 @@ interface GlobalServiceAvailability {
|
|||||||
moodLogGlobal: boolean;
|
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() {
|
export function IntegrationsSection() {
|
||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@@ -64,6 +221,8 @@ export function IntegrationsSection() {
|
|||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
|
||||||
|
|
||||||
const moodLogEnabled = globalServices?.moodLogGlobal ?? true;
|
const moodLogEnabled = globalServices?.moodLogGlobal ?? true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,13 +242,36 @@ export function IntegrationsSection() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<WithingsCard isAuthenticated={isAuthenticated} />
|
<WithingsCard
|
||||||
{moodLogEnabled && <MoodLogCard />}
|
isAuthenticated={isAuthenticated}
|
||||||
|
statusBanner={
|
||||||
|
<IntegrationStatusBanner
|
||||||
|
status={pickStatus(integrationStatus, "withings")}
|
||||||
|
threshold={integrationStatus?.threshold}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{moodLogEnabled && (
|
||||||
|
<MoodLogCard
|
||||||
|
statusBanner={
|
||||||
|
<IntegrationStatusBanner
|
||||||
|
status={pickStatus(integrationStatus, "moodlog")}
|
||||||
|
threshold={integrationStatus?.threshold}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
function WithingsCard({
|
||||||
|
isAuthenticated,
|
||||||
|
statusBanner,
|
||||||
|
}: {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
statusBanner: React.ReactNode;
|
||||||
|
}) {
|
||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||||
@@ -129,6 +311,7 @@ function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["withings"] });
|
queryClient.invalidateQueries({ queryKey: ["withings"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,6 +336,7 @@ function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
|||||||
);
|
);
|
||||||
setSyncMsgType("success");
|
setSyncMsgType("success");
|
||||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
|
||||||
} else {
|
} else {
|
||||||
setSyncMsg(json.error || t("settings.withingsSyncFailed"));
|
setSyncMsg(json.error || t("settings.withingsSyncFailed"));
|
||||||
setSyncMsgType("error");
|
setSyncMsgType("error");
|
||||||
@@ -239,6 +423,7 @@ function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
|
{statusBanner}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium">
|
<h3 className="text-sm font-medium">
|
||||||
{t("settings.withingsCredentials")}
|
{t("settings.withingsCredentials")}
|
||||||
@@ -417,8 +602,9 @@ function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoodLogCard() {
|
function MoodLogCard({ statusBanner }: { statusBanner: React.ReactNode }) {
|
||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -450,6 +636,7 @@ function MoodLogCard() {
|
|||||||
setUrl("");
|
setUrl("");
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
await refetchStatus();
|
await refetchStatus();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
|
||||||
} else {
|
} else {
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setMsg(json.error || t("settings.savingError"));
|
setMsg(json.error || t("settings.savingError"));
|
||||||
@@ -477,6 +664,7 @@ function MoodLogCard() {
|
|||||||
);
|
);
|
||||||
setMsgType("success");
|
setMsgType("success");
|
||||||
await refetchStatus();
|
await refetchStatus();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
|
||||||
} else {
|
} else {
|
||||||
setMsg(t("settings.moodLogSyncFailed"));
|
setMsg(t("settings.moodLogSyncFailed"));
|
||||||
setMsgType("error");
|
setMsgType("error");
|
||||||
@@ -494,6 +682,7 @@ function MoodLogCard() {
|
|||||||
setMsg(t("settings.moodLogDisconnected"));
|
setMsg(t("settings.moodLogDisconnected"));
|
||||||
setMsgType("success");
|
setMsgType("success");
|
||||||
await refetchStatus();
|
await refetchStatus();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +714,8 @@ function MoodLogCard() {
|
|||||||
{t("settings.moodLogDescription")}
|
{t("settings.moodLogDescription")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{statusBanner}
|
||||||
|
|
||||||
<form onSubmit={handleSave} className="space-y-3">
|
<form onSubmit={handleSave} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>{t("settings.moodLogUrl")}</Label>
|
<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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
|
|||||||
"devices",
|
"devices",
|
||||||
"feedback",
|
"feedback",
|
||||||
"idempotency_keys",
|
"idempotency_keys",
|
||||||
|
"integration_statuses",
|
||||||
"measurements",
|
"measurements",
|
||||||
"medication_intake_events",
|
"medication_intake_events",
|
||||||
"medication_schedules",
|
"medication_schedules",
|
||||||
|
|||||||
Reference in new issue
Block a user