feat(fitbit): add the Google Health settings card with connection test and resume

Give the Google Health (Fitbit & Pixel) integration the same settings-card
parity WHOOP and Withings already have. It is a BYO-key provider — the user
pastes their own Google OAuth client id/secret, then connects — so the card
mirrors the WHOOP flow rather than the server-brokered Withings one.

- New POST /api/integrations/fitbit/test does a live, rate-limited
  (5/60s per user) probe against the Google Health profile endpoint via
  the refreshed token from getValidToken, returning { ok, latencyMs,
  lastSyncedAt } and the same error-code union WHOOP uses. The Bearer
  token and upstream host are scrubbed before any annotation.
- New POST /api/integrations/fitbit/resume clears a parked integration
  via resumeIntegrationFromPark, rate-limited to match the test route.
- FitbitCard renders the credentials form, OAuth connect, sync now /
  sync all data / test connection / disconnect action row, the parked
  resume banner, and a backfill-in-progress line. Status reads from the
  existing /api/fitbit/status; the pill/error/parked state comes off the
  cross-integration envelope, which now carries a fitbit entry.
- Card strings added under settings.fitbit* across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 22:37:58 +02:00
1 parent 14469a6de9
commit f44dccb828
12 files changed
+1078 -40

No files matched your search

@@ -0,0 +1,48 @@
/**
* Manual resume endpoint for a parked Fitbit / Google Health integration.
*
* Background: the `parked` state lives in
* `src/lib/integrations/status.ts`. Once a Fitbit persistent-failure
* streak has been running for more than 24h, the status writer flips
* the row to `parked` and the sync entry-point short-circuits. The user
* (or an operator) calls this endpoint to clear the park and let the
* next cron tick attempt the sync again.
*
* Rate-limited (5/min per user) to match the existing
* `/api/integrations/fitbit/test` endpoint — there's no upstream call
* here, but a misbehaving client tab loop would still rack up
* audit-log rows if the limit was absent.
*
* Returns 200 with `{ resumed: true, wasParked: boolean }`. The
* `wasParked` flag lets the caller decide whether to surface a "you
* resumed it" toast or treat the action as a no-op (the row was
* already connected / disconnected). Mirrors the WHOOP resume route.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.fitbit.resume" } });
const rl = await checkRateLimit(`fitbit-resume:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many resume requests", 429, {
errorCode: "rate_limited_self",
});
}
const result = await resumeIntegrationFromPark(user.id, "fitbit");
annotate({
meta: {
fitbit_resume_was_parked: result.wasParked,
},
});
return apiSuccess({ resumed: true, wasParked: result.wasParked });
});
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
fitbitConnection: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((x: string) => x),
}));
vi.mock("@/lib/fitbit/sync", () => ({
getValidToken: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getValidToken } from "@/lib/fitbit/sync";
interface ApiErrorEnvelope {
data: null;
error: string;
}
interface ApiSuccessEnvelope<T> {
data: T;
error: null;
}
const originalFetch = global.fetch;
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.fitbitConnection.findUnique).mockReset();
vi.mocked(getValidToken).mockReset();
global.fetch = vi.fn() as never;
});
afterEach(() => {
global.fetch = originalFetch;
});
function emptyRequest(): Request {
return new Request("http://localhost/api/integrations/fitbit/test", {
method: "POST",
headers: { "content-length": "0" },
});
}
describe("POST /api/integrations/fitbit/test", () => {
it("happy path returns ok with shape", async () => {
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "plain-access",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify({ name: "users/1" }), {
status: 200,
}),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
const body = (await response.json()) as ApiSuccessEnvelope<{
ok: boolean;
lastSyncedAt: string;
latencyMs: number;
}>;
expect(body.data.ok).toBe(true);
expect(typeof body.data.latencyMs).toBe("number");
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
});
it("rate-limit denial returns 429 with no upstream call", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
expect(global.fetch).not.toHaveBeenCalled();
});
it("returns 422 when no Fitbit connection exists", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce(null);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.error).toMatch(/not connected/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("classifies 401 upstream as credentials_rejected", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "sk-secret",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt: null,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(502);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("credentials_rejected");
});
it("classifies 403 upstream as credentials_rejected", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "plain-access",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt: null,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify({ error: "forbidden" }), { status: 403 }),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(502);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("credentials_rejected");
});
it("classifies 429 upstream as rate_limited", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "plain-access",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt: null,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(502);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("rate_limited");
});
it("classifies 500 upstream as upstream_error", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "plain-access",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt: null,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response("oops", { status: 503 }),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(502);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("upstream_error");
});
it("does not leak Bearer token or upstream URL on failure", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "sk-secret",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt: null,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(
JSON.stringify({
error: "Authorization: Bearer sk-secret echoed",
url: "https://health.googleapis.com/v4/users/me/profile",
}),
{ status: 401 },
),
);
const response = await POST(emptyRequest() as never);
const text = await response.text();
expect(response.status).toBe(502);
expect(text).not.toMatch(/sk-/);
expect(text).not.toMatch(/health\.googleapis\.com/);
});
});
@@ -0,0 +1,132 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { getValidToken } from "@/lib/fitbit/sync";
import { FITBIT_API_BASE } from "@/lib/fitbit/client";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
// Cheap authenticated probe — the single (non-paginated) profile endpoint the
// Fitbit client already calls via `fetchProfile`.
// Base from `src/lib/fitbit/client.ts` (`FITBIT_API_BASE`).
const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/users/me/profile`;
const TIMEOUT_MS = 5_000;
type CategorisedError = { code: string; message: string };
function categoriseHttpStatus(status: number): CategorisedError {
if (status === 401 || status === 403) {
return {
code: "credentials_rejected",
message: "Google Health rejected the credentials",
};
}
if (status === 429) {
return {
code: "rate_limited",
message: "Google Health rate-limited the request",
};
}
if (status >= 500) {
return {
code: "upstream_error",
message: "Google Health returned a server error",
};
}
return {
code: "connection_failed",
message: "Google Health connection failed",
};
}
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
annotate({ action: { name: "integrations.fitbit.test" } });
const rl = await checkRateLimit(`fitbit-test:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many test requests", 429, {
errorCode: "rate_limited_self",
});
}
const tokenInfo = await getValidToken(user.id);
if (!tokenInfo) {
return apiError("Google Health not connected", 422, {
errorCode: "not_configured",
});
}
const connection = await prisma.fitbitConnection.findUnique({
where: { userId: user.id },
select: { lastSyncedAt: true },
});
const start = performance.now();
try {
const res = await safeFetch(
FITBIT_PROFILE_URL,
{
method: "GET",
headers: {
Authorization: `Bearer ${tokenInfo.accessToken}`,
},
},
{ timeoutMs: TIMEOUT_MS },
);
const latencyMs = Math.round(performance.now() - start);
if (!res.ok) {
const cat = categoriseHttpStatus(res.status);
annotate({
meta: {
fitbit_test_status: res.status,
fitbit_test_code: cat.code,
fitbit_test_latency_ms: latencyMs,
},
});
return apiError(cat.message, 502, { errorCode: cat.code });
}
return apiSuccess({
ok: true,
lastSyncedAt: connection?.lastSyncedAt ?? null,
latencyMs,
});
} catch (e) {
const err = e as Error;
// safeFetch wraps timeouts into SafeFetchError{kind:"timeout"};
// legacy AbortError name is preserved for compatibility with any
// future direct-fetch path that still reaches this catch.
const isAbort =
(e instanceof SafeFetchError && e.kind === "timeout") ||
err.name === "AbortError";
const code = isAbort ? "timeout" : "connection_failed";
annotate({
meta: {
fitbit_test_code: code,
fitbit_test_error: redact(err.message).slice(0, 300),
},
});
return apiError(
isAbort
? "Google Health request timed out"
: "Google Health connection failed",
502,
{ errorCode: code },
);
}
});
// Strip any `Bearer <token>` substring before annotating; the catch path can
// see DNS / TLS errors that include the request init via err.cause in some
// runtimes, and we never want a token in Loki.
function redact(text: string): string {
return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]");
}
+76 -35
View File
@@ -29,41 +29,60 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.status" } });
const [withingsStatus, moodLogStatus, whoopStatus, dbUser, withingsConn, whoopConn] =
await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
getIntegrationStatus(user.id, "whoop"),
prisma.user.findUnique({
where: { id: user.id },
select: {
withingsClientIdEncrypted: true,
withingsClientSecretEncrypted: true,
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
moodLogLastSyncedAt: true,
},
}),
prisma.withingsConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
prisma.whoopConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
]);
const [
withingsStatus,
moodLogStatus,
whoopStatus,
fitbitStatus,
dbUser,
withingsConn,
whoopConn,
fitbitConn,
] = await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
getIntegrationStatus(user.id, "whoop"),
getIntegrationStatus(user.id, "fitbit"),
prisma.user.findUnique({
where: { id: user.id },
select: {
withingsClientIdEncrypted: true,
withingsClientSecretEncrypted: true,
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
moodLogLastSyncedAt: true,
},
}),
prisma.withingsConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
prisma.whoopConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
prisma.fitbitConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
]);
const now = Date.now();
@@ -106,6 +125,19 @@ export const GET = apiHandler(async () => {
? whoopConn.tokenExpiresAt.getTime() <= now
: null,
} satisfies IntegrationViewModel & WhoopExtras,
{
...fitbitStatus,
configured:
!!dbUser?.fitbitClientIdEncrypted &&
!!dbUser?.fitbitClientSecretEncrypted,
connected: !!fitbitConn,
connectedAt: fitbitConn?.createdAt?.toISOString() ?? null,
legacyLastSyncedAt: fitbitConn?.lastSyncedAt?.toISOString() ?? null,
tokenExpiresAt: fitbitConn?.tokenExpiresAt?.toISOString() ?? null,
tokenExpired: fitbitConn
? fitbitConn.tokenExpiresAt.getTime() <= now
: null,
} satisfies IntegrationViewModel & FitbitExtras,
],
});
});
@@ -141,3 +173,12 @@ interface WhoopExtras {
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
}
interface FitbitExtras {
configured: boolean;
connected: boolean;
connectedAt: string | null;
legacyLastSyncedAt: string | null;
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
}