mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/v14311-integ' into release/v1.43.11
This commit is contained in:
commit
eaaff23858
88 files changed
+3311
-384
No files matched your search
@@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"insights.derived.composite.READINESS.component.hrv",
|
||||
"labs.catalog.hs-crp",
|
||||
"measurements.typeHrvRmssd",
|
||||
"illness.vital.HRV_RMSSD",
|
||||
"settings.sections.sources.metrics.hrvRmssd",
|
||||
"measurements.typeVo2Max",
|
||||
// "{hours} h {minutes} min": the SI symbols for hour and minute.
|
||||
|
||||
@@ -56,6 +56,7 @@ import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
function connectRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/fitbit/connect", {
|
||||
@@ -112,4 +113,16 @@ describe("GET /api/fitbit/connect", () => {
|
||||
expect(location).toContain("reason=nocreds");
|
||||
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it("redirects to reason=retired once Google has turned the Web API off", async () => {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(true);
|
||||
try {
|
||||
const response = await GET(connectRequest());
|
||||
const location = response.headers.get("location") ?? "";
|
||||
expect(location).toContain("fitbit=error");
|
||||
expect(location).toContain("reason=retired");
|
||||
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { relativeRedirect } from "@/lib/http/relative-redirect";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
/**
|
||||
* Redirect the user to the Fitbit OAuth consent page (classic
|
||||
@@ -57,6 +58,15 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// A grant issued now would never sync anything: Google has turned the Web
|
||||
// API off. The card explains the move to Google Health.
|
||||
if (isFitbitRetired()) {
|
||||
annotate({ action: { name: "fitbit.connect.retired" } });
|
||||
return relativeRedirect(
|
||||
"/settings/integrations?fitbit=error&reason=retired",
|
||||
);
|
||||
}
|
||||
|
||||
const creds = await getUserFitbitCredentials(user.id);
|
||||
if (!creds) {
|
||||
annotate({ action: { name: "fitbit.connect.no_credentials" } });
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* POST /api/fitbit/sync once Google has turned the Fitbit Web API off: a
|
||||
* clear 410 naming the cause, and no sync attempt behind it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type * as ApiHandlerModule from "@/lib/api-handler";
|
||||
|
||||
const { syncUserFitbitMock } = vi.hoisted(() => ({
|
||||
syncUserFitbitMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
|
||||
return {
|
||||
...actual,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/fitbit/sync", () => ({ syncUserFitbit: syncUserFitbitMock }));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
const post = POST as unknown as (r: NextRequest) => Promise<Response>;
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/fitbit/sync", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
syncUserFitbitMock.mockResolvedValue({ imported: 2, failed: false });
|
||||
});
|
||||
|
||||
describe("POST /api/fitbit/sync", () => {
|
||||
it("syncs while the Web API is still up", async () => {
|
||||
const response = await post(request());
|
||||
expect(response.status).toBe(200);
|
||||
expect(syncUserFitbitMock).toHaveBeenCalledWith("u1", {
|
||||
fullSync: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers 410 provider_retired after the turndown and syncs nothing", async () => {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(true);
|
||||
try {
|
||||
const response = await post(request());
|
||||
expect(response.status).toBe(410);
|
||||
const body = (await response.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("provider_retired");
|
||||
expect(body.error).toMatch(/Google Health/);
|
||||
expect(syncUserFitbitMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readSyncTriggerBody } from "@/lib/integrations/sync-request";
|
||||
import { syncUserFitbit } from "@/lib/fitbit/sync";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -31,6 +32,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (isFitbitRetired()) {
|
||||
return apiError(
|
||||
"Google has turned the Fitbit Web API off. Connect Google Health instead.",
|
||||
410,
|
||||
{ errorCode: "provider_retired" },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await readSyncTriggerBody(request);
|
||||
if (body.error) return body.error;
|
||||
const { fullSync } = body;
|
||||
|
||||
@@ -36,6 +36,7 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getValidToken } from "@/lib/fitbit/sync-core";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
@@ -109,6 +110,22 @@ describe("POST /api/integrations/fitbit/test", () => {
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers 410 provider_retired once Google has turned the Web API off", async () => {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(true);
|
||||
try {
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(410);
|
||||
const body = (await response.json()) as ApiErrorEnvelope & {
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("provider_retired");
|
||||
expect(getValidToken).not.toHaveBeenCalled();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 422 when no Fitbit connection exists", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { prisma } from "@/lib/db";
|
||||
import { getValidToken } from "@/lib/fitbit/sync-core";
|
||||
import { FITBIT_API_BASE } from "@/lib/fitbit/client";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -55,6 +56,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (isFitbitRetired()) {
|
||||
return apiError(
|
||||
"Google has turned the Fitbit Web API off. Connect Google Health instead.",
|
||||
410,
|
||||
{ errorCode: "provider_retired" },
|
||||
);
|
||||
}
|
||||
|
||||
const tokenInfo = await getValidToken(user.id);
|
||||
if (!tokenInfo) {
|
||||
return apiError("Fitbit not connected", 422, {
|
||||
|
||||
@@ -57,6 +57,10 @@ import {
|
||||
type GoogleHealthBackfillEndedEntry,
|
||||
} from "@/lib/google-health/backfill-progress";
|
||||
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
|
||||
import {
|
||||
fitbitRetirement,
|
||||
type FitbitRetirement,
|
||||
} from "@/lib/fitbit/retirement";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -378,6 +382,7 @@ export const GET = apiHandler(async () => {
|
||||
? fitbitConn.tokenExpiresAt.getTime() <= now
|
||||
: null,
|
||||
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
|
||||
retirement: fitbitRetirement(),
|
||||
health: health["fitbit"],
|
||||
...resolve("fitbit", fitbitStatus, {
|
||||
connected: fitbitConnected,
|
||||
@@ -558,6 +563,8 @@ interface FitbitExtras {
|
||||
tokenExpiresAt: string | null;
|
||||
tokenExpired: boolean | null;
|
||||
backfillCompleted: boolean | null;
|
||||
/** The end of the legacy Fitbit Web API (`@/lib/fitbit/retirement`). */
|
||||
retirement: FitbitRetirement;
|
||||
}
|
||||
|
||||
// v1.27.0 — Google Health mirrors the Fitbit shape plus `needsReauth`: Google
|
||||
|
||||
@@ -67,13 +67,21 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
syncUserPolarMock.mockResolvedValue({ imported: 0, failed: false });
|
||||
syncUserPolarMock.mockResolvedValue({
|
||||
imported: 0,
|
||||
failed: false,
|
||||
tokenProven: true,
|
||||
});
|
||||
syncUserPolarWorkoutsMock.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("POST /api/polar/sync", () => {
|
||||
it("runs both Polar legs and totals what they imported", async () => {
|
||||
syncUserPolarMock.mockResolvedValue({ imported: 5, failed: false });
|
||||
syncUserPolarMock.mockResolvedValue({
|
||||
imported: 5,
|
||||
failed: false,
|
||||
tokenProven: true,
|
||||
});
|
||||
syncUserPolarWorkoutsMock.mockResolvedValue(2);
|
||||
|
||||
const response = await post(request());
|
||||
@@ -84,7 +92,10 @@ describe("POST /api/polar/sync", () => {
|
||||
error: null,
|
||||
});
|
||||
expect(syncUserPolarMock).toHaveBeenCalledWith("u1");
|
||||
expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1");
|
||||
// The vitals leg tells the workouts leg the token just worked.
|
||||
expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1", {
|
||||
tokenProven: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call a run whose vitals collections all failed a success", async () => {
|
||||
|
||||
@@ -191,14 +191,16 @@ describe("POST /api/whoop/webhook/[token]", () => {
|
||||
expect(getGlobalBoss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("soft-deletes matching rows on a `*.deleted` event", async () => {
|
||||
it("soft-deletes the recovery's own rows on a `recovery.deleted` event", async () => {
|
||||
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValue({
|
||||
userId: "user-1",
|
||||
} as never);
|
||||
|
||||
// The v2 recovery webhook id is the associated sleep's UUID, which also
|
||||
// prefixes the night's sleep rows — the match names the recovery's ids.
|
||||
const req = signedRequest({
|
||||
user_id: 42,
|
||||
id: "rec-uuid",
|
||||
id: "sleep-uuid",
|
||||
type: "recovery.deleted",
|
||||
});
|
||||
const res = await POST(req, ctx(SECRET));
|
||||
@@ -208,7 +210,15 @@ describe("POST /api/whoop/webhook/[token]", () => {
|
||||
where: {
|
||||
userId: "user-1",
|
||||
source: "WHOOP",
|
||||
externalId: { startsWith: "rec-uuid:" },
|
||||
externalId: {
|
||||
in: [
|
||||
"sleep-uuid:recovery",
|
||||
"sleep-uuid:hrv_rmssd",
|
||||
"sleep-uuid:rhr",
|
||||
"sleep-uuid:spo2",
|
||||
"sleep-uuid:skin_temp",
|
||||
],
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: expect.any(Date), syncVersion: { increment: 1 } },
|
||||
|
||||
@@ -246,8 +246,18 @@ describe("POST /api/workouts/batch — rate limit buckets", () => {
|
||||
describe("POST /api/workouts/batch — what a scoped caller writes", () => {
|
||||
it("attributes its rows to EXTERNAL", async () => {
|
||||
armToken([WORKOUTS_WRITE_SCOPE]);
|
||||
// Two sessions of the day: EXTERNAL ranks on the workout ladder, so two
|
||||
// rows of one session would land once.
|
||||
const res = await POST(
|
||||
makeRequest({ workouts: [workout("s-1"), workout("s-2")] }),
|
||||
makeRequest({
|
||||
workouts: [
|
||||
workout("s-1"),
|
||||
workout("s-2", {
|
||||
startedAt: "2026-09-20T18:00:00.000Z",
|
||||
endedAt: "2026-09-20T18:45:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const rows = insertedRows();
|
||||
|
||||
@@ -71,6 +71,15 @@ describe("oauthReasonKey", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("names the Fitbit turndown refusal with its own copy, and only for Fitbit", () => {
|
||||
expect(oauthReasonKey("fitbit", "retired")).toBe(
|
||||
"settings.fitbitOauthError.retired",
|
||||
);
|
||||
expect(oauthReasonKey("whoop", "retired")).toBe(
|
||||
"settings.whoopOauthError.generic",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to generic for an unknown tag", () => {
|
||||
expect(oauthReasonKey("whoop", "totally_unknown")).toBe(
|
||||
"settings.whoopOauthError.generic",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The Fitbit card's notice about the end of the legacy Web API: before the
|
||||
* day it says when the connection stops, from the day on that it has, and
|
||||
* both times it points at Google Health and says the data stays.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { FitbitRetirementNotice } from "../fitbit-retirement-notice";
|
||||
|
||||
function render(ended: boolean | null) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<FitbitRetirementNotice
|
||||
retirement={
|
||||
ended === null
|
||||
? undefined
|
||||
: { endsOn: "2026-10-30", ended, successor: "google-health" }
|
||||
}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<FitbitRetirementNotice>", () => {
|
||||
it("names the day the connection stops, and the way on", () => {
|
||||
const html = render(false);
|
||||
expect(html).toContain('data-ended="false"');
|
||||
expect(html).toContain("Google turns the Fitbit Web API off on");
|
||||
expect(html).toContain("Google Health reads the same Fitbit and Pixel");
|
||||
expect(html).toContain("Everything already imported stays.");
|
||||
expect(html).toContain('href="#google-health"');
|
||||
expect(html).toContain("/google-health");
|
||||
});
|
||||
|
||||
it("says the connection has stopped once the day has come", () => {
|
||||
const html = render(true);
|
||||
expect(html).toContain('data-ended="true"');
|
||||
expect(html).toContain("Google turned the Fitbit Web API off on");
|
||||
expect(html).not.toContain("Google turns the Fitbit Web API off on");
|
||||
});
|
||||
|
||||
it("renders nothing without a retirement on the status entry", () => {
|
||||
expect(render(null)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
|
||||
import { DawarichCard } from "@/components/settings/integrations/dawarich-card";
|
||||
import { FitbitRetirementNotice } from "@/components/settings/integrations/fitbit-retirement-notice";
|
||||
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
|
||||
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
|
||||
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
|
||||
@@ -136,6 +137,9 @@ const PROVIDER_ONLY_REASONS: Partial<
|
||||
Record<OAuthOutcomeProvider, ReadonlySet<string>>
|
||||
> = {
|
||||
oura: new Set(["issuer"]),
|
||||
// Fitbit's `retired`: Google has turned the Web API off, so the connect
|
||||
// route refuses a new grant.
|
||||
fitbit: new Set(["retired"]),
|
||||
};
|
||||
|
||||
type OAuthOutcome =
|
||||
@@ -216,6 +220,8 @@ export function ConnectionsPanel({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
|
||||
const fitbitStatus = pickStatus(integrationStatus, "fitbit");
|
||||
const fitbitRetired = fitbitStatus?.retirement?.ended === true;
|
||||
|
||||
// OAuth callback handler — reads `?withings=connected|error&reason=…`
|
||||
// from the URL (lazy initialiser, same shape as the Codex handler in
|
||||
@@ -332,20 +338,35 @@ export function ConnectionsPanel({
|
||||
/>
|
||||
</div>
|
||||
<div id="fitbit" className="scroll-mt-28">
|
||||
<FitbitRetirementNotice retirement={fitbitStatus?.retirement} />
|
||||
<OAuthIntegrationCard
|
||||
config={OAUTH_INTEGRATIONS.fitbit}
|
||||
viewModel={pickStatus(integrationStatus, "fitbit")}
|
||||
viewModel={fitbitStatus}
|
||||
callbackUrl={callbackUrls.fitbit}
|
||||
syncDisabled={fitbitRetired}
|
||||
connectDisabled={fitbitRetired}
|
||||
titleAccessory={
|
||||
<>
|
||||
<TagChip>{t("settings.fitbitTag")}</TagChip>
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="fitbit-experimental-badge"
|
||||
className="border-warning/50 text-warning"
|
||||
>
|
||||
{t("settings.fitbitExperimentalBadge")}
|
||||
</Badge>
|
||||
{fitbitStatus?.retirement ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="fitbit-retirement-badge"
|
||||
className="border-warning/50 text-warning"
|
||||
>
|
||||
{fitbitRetired
|
||||
? t("settings.fitbitRetirement.badgeEnded")
|
||||
: t("settings.fitbitRetirement.badgeEnding")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="fitbit-experimental-badge"
|
||||
className="border-warning/50 text-warning"
|
||||
>
|
||||
{t("settings.fitbitExperimentalBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ExternalLink } from "lucide-react";
|
||||
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { FitbitRetirement } from "@/lib/fitbit/retirement";
|
||||
|
||||
import { integrationDocsHref } from "./setup-guide-link";
|
||||
|
||||
/**
|
||||
* The end of the legacy Fitbit Web API, on the Fitbit card. Before the day it
|
||||
* says when the connection stops; from the day on, that it has stopped. Both
|
||||
* name the way on: Google Health reads the same devices, needs one more
|
||||
* consent, and leaves everything already imported in place. The server owns
|
||||
* the date and whether it has passed (`retirement` on the status envelope).
|
||||
*/
|
||||
export function FitbitRetirementNotice({
|
||||
retirement,
|
||||
}: {
|
||||
retirement: FitbitRetirement | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
if (!retirement) return null;
|
||||
const date = fmt.dateShortSmartCalendar(retirement.endsOn);
|
||||
return (
|
||||
<div
|
||||
role="note"
|
||||
data-testid="fitbit-retirement-notice"
|
||||
data-ended={retirement.ended ? "true" : "false"}
|
||||
className="border-warning/40 bg-warning/5 text-foreground mb-3 space-y-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<p>
|
||||
{retirement.ended
|
||||
? t("settings.fitbitRetirement.ended", { date })
|
||||
: t("settings.fitbitRetirement.ending", { date })}
|
||||
</p>
|
||||
<p>{t("settings.fitbitRetirement.move")}</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<InlineAction asChild size="sm">
|
||||
<a href="#google-health" data-testid="fitbit-retirement-to-google">
|
||||
{t("settings.fitbitRetirement.toCard")}
|
||||
<ArrowDown className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
</InlineAction>
|
||||
<InlineAction asChild size="sm">
|
||||
<a
|
||||
href={integrationDocsHref("google-health")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="fitbit-retirement-guide"
|
||||
>
|
||||
{t("settings.fitbitRetirement.guide")}
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
</InlineAction>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -220,6 +220,10 @@ export function OAuthIntegrationCard(props: {
|
||||
callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey];
|
||||
icon?: LucideIcon;
|
||||
titleAccessory?: ReactNode;
|
||||
/** The provider cannot sync any more (Fitbit after its API turndown). */
|
||||
syncDisabled?: boolean;
|
||||
/** …and cannot be connected either. */
|
||||
connectDisabled?: boolean;
|
||||
}) {
|
||||
const { config } = props;
|
||||
const sync = useStandardSync({
|
||||
@@ -229,7 +233,13 @@ export function OAuthIntegrationCard(props: {
|
||||
fullSyncResult: config.fullSyncResult,
|
||||
statusQueryKey: config.statusQueryKey(),
|
||||
});
|
||||
return <OAuthIntegrationCardView {...props} sync={sync} />;
|
||||
return (
|
||||
<OAuthIntegrationCardView
|
||||
{...props}
|
||||
sync={sync}
|
||||
fullSyncDisabled={props.syncDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,6 +258,7 @@ export function OAuthIntegrationCardView({
|
||||
fullSyncDisabled,
|
||||
syncResults,
|
||||
details,
|
||||
connectDisabled,
|
||||
}: {
|
||||
config: OAuthIntegrationConfig;
|
||||
viewModel: IntegrationStatusViewModel | undefined;
|
||||
@@ -263,6 +274,8 @@ export function OAuthIntegrationCardView({
|
||||
syncResults?: ReactNode;
|
||||
/** Provider extras for the details section. */
|
||||
details?: ReactNode;
|
||||
/** Greys out "Connect" whatever the credentials say. */
|
||||
connectDisabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -374,7 +387,7 @@ export function OAuthIntegrationCardView({
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
disabled={!configured}
|
||||
disabled={!configured || connectDisabled}
|
||||
onClick={connect}
|
||||
data-slot="integration-primary-action"
|
||||
data-action="connect"
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { GoogleHealthBackfillEndedEntry } from "@/lib/google-health/backfil
|
||||
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
|
||||
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { FitbitRetirement } from "@/lib/fitbit/retirement";
|
||||
|
||||
export type IntegrationKey =
|
||||
| "withings"
|
||||
@@ -71,6 +72,8 @@ export interface IntegrationStatusViewModel {
|
||||
hasToken?: boolean;
|
||||
/** Per-metric last-seen timestamps with the server-computed stale flag. */
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
/** Fitbit: the end of the legacy Web API, resolved on the server. */
|
||||
retirement?: FitbitRetirement;
|
||||
}
|
||||
|
||||
export interface IntegrationStatusEnvelope {
|
||||
|
||||
@@ -519,25 +519,23 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker",
|
||||
/**
|
||||
* v1.38.x — what the EXTERNAL source does to a contested day.
|
||||
*
|
||||
* `EXTERNAL` is on no priority ladder, deliberately: the `steps` and `spo2`
|
||||
* defaults already sit at `metricSourceLadder`'s `.max(8)`, so a ninth entry
|
||||
* would come back from `GET /api/auth/me/source-priority`, be PUT back whole
|
||||
* by the settings page, and 422 — breaking ladder saves for everyone.
|
||||
* `EXTERNAL` was on no priority ladder at first, because the ladders were
|
||||
* capped at eight entries and a ninth would have 422'd every settings save.
|
||||
* The cap is the number of sources now, and since v1.43.11 `EXTERNAL` sits
|
||||
* right after `MANUAL` on the ladders that rank `MANUAL`.
|
||||
*
|
||||
* That omission is not inert. It decides a real behaviour, and one that
|
||||
* CHANGED when ingested rows stopped being labelled `MANUAL`: before, a typed
|
||||
* reading and a bridged one were the same source and both survived a day; now
|
||||
* `MANUAL` ranks, `EXTERNAL` does not, and the bridged row leaves the
|
||||
* canonical set for that day. Seven call sites consume this picker (daily
|
||||
* totals, the health score, the doctor report, correlations, mood crosstabs,
|
||||
* PR detection), so the effect is user-visible and is written up in the
|
||||
* changelog.
|
||||
* The behaviour this pins CHANGED when ingested rows stopped being labelled
|
||||
* `MANUAL`: before, a typed reading and a bridged one were the same source
|
||||
* and both survived a day; now `MANUAL` outranks `EXTERNAL`, and the bridged
|
||||
* row leaves the canonical set for that day. Seven call sites consume this
|
||||
* picker (daily totals, the health score, the doctor report, correlations,
|
||||
* mood crosstabs, PR detection), so the effect is user-visible.
|
||||
*
|
||||
* Pinned here because it is emergent from a rank ladder rather than stated
|
||||
* anywhere in code: put `EXTERNAL` on a ladder and the first case below goes
|
||||
* red, which is the review anybody adding it deserves.
|
||||
* anywhere in code: rank `EXTERNAL` above `MANUAL` and the first case below
|
||||
* goes red, which is the review anybody doing that deserves.
|
||||
*/
|
||||
describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => {
|
||||
describe("pickCanonicalSourceRows — the EXTERNAL source", () => {
|
||||
const day = "2026-09-02";
|
||||
|
||||
function weightRow(
|
||||
@@ -573,10 +571,9 @@ describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => {
|
||||
expect(out.pickedByDay.get(day)).toBe("MANUAL");
|
||||
});
|
||||
|
||||
it("keeps a bridged-only day, since nothing on the ladder is present", () => {
|
||||
// The `!picked` pass-through. Without this arm an account that ONLY ever
|
||||
// pushes readings would go dark on every surface listed above, which
|
||||
// would be a far worse regression than the one above is a change.
|
||||
it("keeps a bridged-only day, picked from the ladder", () => {
|
||||
// An account that ONLY ever pushes readings keeps every one of them:
|
||||
// the bridge is the day's one source, and the ladder now names it.
|
||||
const out = pickCanonicalSourceRows(
|
||||
[
|
||||
weightRow("EXTERNAL", 82.4, "06:30"),
|
||||
@@ -588,8 +585,7 @@ describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => {
|
||||
);
|
||||
expect(out.canonicalRows).toHaveLength(2);
|
||||
expect(out.canonicalRows.every((r) => r.source === "EXTERNAL")).toBe(true);
|
||||
// No ladder source won, so the day names no pick.
|
||||
expect(out.pickedByDay.has(day)).toBe(false);
|
||||
expect(out.pickedByDay.get(day)).toBe("EXTERNAL");
|
||||
});
|
||||
|
||||
it("kept both when the bridged row was still labelled MANUAL", () => {
|
||||
@@ -607,10 +603,9 @@ describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => {
|
||||
expect(out.canonicalRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("lets a user who ranks EXTERNAL themselves have it win", () => {
|
||||
// Not reachable from the settings UI today (the ladder cap keeps it off
|
||||
// the page), but the picker resolves whatever ladder it is handed, and a
|
||||
// stored ladder naming it must be honoured rather than ignored.
|
||||
it("lets a user who ranks EXTERNAL above MANUAL have it win", () => {
|
||||
// The picker resolves whatever ladder it is handed; a stored ladder that
|
||||
// puts the bridge first is honoured.
|
||||
const out = pickCanonicalSourceRows(
|
||||
[
|
||||
weightRow("EXTERNAL", 82.4, "06:30"),
|
||||
|
||||
@@ -332,13 +332,13 @@ describe("metric mappers (classic shapes)", () => {
|
||||
expect(out[0]!.fieldTag).toBe("2026-05-10:spo2");
|
||||
});
|
||||
|
||||
it("maps HRV dailyRmssd into the canonical HEART_RATE_VARIABILITY slot", () => {
|
||||
it("maps HRV dailyRmssd into HRV_RMSSD, apart from the SDNN series", () => {
|
||||
const out = mapHeartRateVariability({
|
||||
hrv: [
|
||||
{ dateTime: "2026-05-10", value: { dailyRmssd: 42.7, deepRmssd: 50 } },
|
||||
],
|
||||
});
|
||||
expect(out[0]!.type).toBe("HEART_RATE_VARIABILITY");
|
||||
expect(out[0]!.type).toBe("HRV_RMSSD");
|
||||
expect(out[0]).toMatchObject({ value: 42.7, unit: "ms" });
|
||||
expect(out[0]!.fieldTag).toBe("2026-05-10:hrv");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* The turndown date of the legacy Fitbit Web API, read off the real module
|
||||
* (the unit setup pins it off for every other suite).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actual = await vi.importActual<typeof import("@/lib/fitbit/retirement")>(
|
||||
"@/lib/fitbit/retirement",
|
||||
);
|
||||
|
||||
describe("Fitbit Web API retirement", () => {
|
||||
it("names the day Google published", () => {
|
||||
// developers.google.com/health/migration: "On October 30, 2026, the
|
||||
// Fitbit Web API will be turned off".
|
||||
expect(actual.FITBIT_WEB_API_TURNDOWN_DATE).toBe("2026-10-30");
|
||||
});
|
||||
|
||||
it("turns at midnight UTC at the start of that day", () => {
|
||||
expect(actual.isFitbitRetired(new Date("2026-10-29T23:59:59.999Z"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(actual.isFitbitRetired(new Date("2026-10-30T00:00:00.000Z"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes the day, whether it has come, and the successor", () => {
|
||||
expect(actual.fitbitRetirement(new Date("2026-10-21T12:00:00Z"))).toEqual({
|
||||
endsOn: "2026-10-30",
|
||||
ended: false,
|
||||
successor: "google-health",
|
||||
});
|
||||
expect(
|
||||
actual.fitbitRetirement(new Date("2026-11-02T12:00:00Z")).ended,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -91,10 +91,12 @@ vi.mock("../sync-workout", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
getValidToken,
|
||||
handleCollectionFetchError,
|
||||
noteHardFailure,
|
||||
upsertFitbitMeasurements,
|
||||
} from "../sync-core";
|
||||
import { isFitbitRetired } from "../retirement";
|
||||
import { syncUserFitbit } from "../sync";
|
||||
import { FitbitApiError } from "../response-classifier";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -453,3 +455,33 @@ describe("syncUserFitbit — backfill collapses rollup recompute to one pass", (
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("after Google turned the Fitbit Web API off", () => {
|
||||
it("syncs nothing and writes neither a success nor a failure", async () => {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(true);
|
||||
try {
|
||||
await expect(syncUserFitbit("u1")).resolves.toEqual({
|
||||
imported: 0,
|
||||
failed: false,
|
||||
});
|
||||
expect(syncUserMetrics).not.toHaveBeenCalled();
|
||||
expect(syncUserSleep).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).not.toHaveBeenCalled();
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("hands out no token and attempts no refresh", async () => {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(true);
|
||||
try {
|
||||
await expect(getValidToken("u1")).resolves.toBeNull();
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -741,15 +741,16 @@ export function mapHeartRateVariability(
|
||||
body: unknown,
|
||||
tz?: string,
|
||||
): FitbitMappedMeasurement[] {
|
||||
// Fitbit's HRV summary is an RMSSD estimator (`value.dailyRmssd`). It lands in
|
||||
// the canonical `HEART_RATE_VARIABILITY` slot that FITBIT occupies in the
|
||||
// source-priority `hrv` ladder (alongside Apple / Oura), keeping cross-source
|
||||
// comparison on one axis. (`HRV_RMSSD` is reserved for the WHOOP-native slot.)
|
||||
// Fitbit's HRV summary is an RMSSD estimator (`value.dailyRmssd`), so it lands
|
||||
// in `HRV_RMSSD` beside WHOOP / Oura / Polar. `HEART_RATE_VARIABILITY` is the
|
||||
// SDNN series; the two estimators are not interchangeable, and a Fitbit night
|
||||
// ranked into the SDNN ladder used to splice RMSSD values into an Apple
|
||||
// Watch SDNN series day by day.
|
||||
const rows = readArray(body, "hrv") as FitbitDailyRow[];
|
||||
return mapDailySummary(
|
||||
rows,
|
||||
{
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
type: "HRV_RMSSD",
|
||||
unit: "ms",
|
||||
fieldTag: "hrv",
|
||||
leaves: ["dailyRmssd"],
|
||||
@@ -987,9 +988,9 @@ export const FITBIT_FIELD_MAP: Record<
|
||||
note: "spo2 summary value.avg",
|
||||
},
|
||||
heartRateVariability: {
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
type: "HRV_RMSSD",
|
||||
unit: "ms",
|
||||
note: "hrv summary value.dailyRmssd (RMSSD estimator); canonical HRV slot",
|
||||
note: "hrv summary value.dailyRmssd (RMSSD estimator), apart from SDNN",
|
||||
},
|
||||
restingHeartRate: {
|
||||
type: "RESTING_HEART_RATE",
|
||||
|
||||
@@ -26,14 +26,14 @@ metric (kg / km); the US default is imperial.
|
||||
Scope: `weight`, `oxygen_saturation`, `heartrate`, `respiratory_rate`. Verified
|
||||
against the dev.fitbit.com reference (response shapes quoted there).
|
||||
|
||||
| Endpoint | MeasurementType | Unit | Source field | Grain | Note |
|
||||
| ---------------------------------------------- | ------------------------ | ----------- | ------------------------------------------- | ----- | ----------------------------------------------------------- |
|
||||
| `/1/user/-/body/log/weight/date/{s}/{e}.json` | `WEIGHT` | kg | `weight[].weight` | log | externalId anchors on `logId`. Picker ranks Withings above. |
|
||||
| `/1/user/-/body/log/fat/date/{s}/{e}.json` | `BODY_FAT` | % | `fat[].fat` | log | externalId anchors on `logId`. |
|
||||
| `/1/user/-/spo2/date/{s}/{e}.json` | `OXYGEN_SATURATION` | % | `[].value.avg` (bare array) | daily | day-keyed externalId. |
|
||||
| `/1/user/-/hrv/date/{s}/{e}.json` | `HEART_RATE_VARIABILITY` | ms | `hrv[].value.dailyRmssd` | daily | RMSSD estimator → canonical HRV slot (not `HRV_RMSSD`). |
|
||||
| `/1/user/-/activities/heart/date/{s}/{e}.json` | `RESTING_HEART_RATE` | bpm | `activities-heart[].value.restingHeartRate` | daily | rows with no resting HR for the day are skipped. |
|
||||
| `/1/user/-/br/date/{s}/{e}.json` | `RESPIRATORY_RATE` | breaths/min | `br[].value.breathingRate` | daily | |
|
||||
| Endpoint | MeasurementType | Unit | Source field | Grain | Note |
|
||||
| ---------------------------------------------- | -------------------- | ----------- | ------------------------------------------- | ----- | ------------------------------------------------------------------- |
|
||||
| `/1/user/-/body/log/weight/date/{s}/{e}.json` | `WEIGHT` | kg | `weight[].weight` | log | externalId anchors on `logId`. Picker ranks Withings above. |
|
||||
| `/1/user/-/body/log/fat/date/{s}/{e}.json` | `BODY_FAT` | % | `fat[].fat` | log | externalId anchors on `logId`. |
|
||||
| `/1/user/-/spo2/date/{s}/{e}.json` | `OXYGEN_SATURATION` | % | `[].value.avg` (bare array) | daily | day-keyed externalId. |
|
||||
| `/1/user/-/hrv/date/{s}/{e}.json` | `HRV_RMSSD` | ms | `hrv[].value.dailyRmssd` | daily | RMSSD estimator, kept apart from the SDNN `HEART_RATE_VARIABILITY`. |
|
||||
| `/1/user/-/activities/heart/date/{s}/{e}.json` | `RESTING_HEART_RATE` | bpm | `activities-heart[].value.restingHeartRate` | daily | rows with no resting HR for the day are skipped. |
|
||||
| `/1/user/-/br/date/{s}/{e}.json` | `RESPIRATORY_RATE` | breaths/min | `br[].value.breathingRate` | daily | |
|
||||
|
||||
Each daily-summary value passes a finite + strictly-positive guard (a 0/NaN is a
|
||||
garbage/empty reading and is dropped). Each endpoint caps its own date range
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The end of the legacy Fitbit Web API.
|
||||
*
|
||||
* Google turns it off on 30 October 2026: "On October 30, 2026, the Fitbit
|
||||
* Web API will be turned off and will no longer function or sync data to or
|
||||
* from Fitbit users" (https://developers.google.com/health/migration and the
|
||||
* August 2026 Google Health developer newsletter; support already ended on
|
||||
* 30 September 2026). Its successor is the Google Health API, which reads the
|
||||
* same Fitbit and Pixel devices. Access and refresh tokens cannot be carried
|
||||
* over, so a person moves by connecting Google Health and consenting once more.
|
||||
*
|
||||
* From the turndown on, HealthLog makes no Web API call for any Fitbit
|
||||
* connection: no poll, no backfill, no sleep repair, no token refresh, and no
|
||||
* new connection. The connection row and everything it imported stay. The
|
||||
* connection reads as not connected to the health read and the watchdog, so a
|
||||
* silence that is the provider's own shutdown pages nobody and asks nobody to
|
||||
* reconnect; the card keeps explaining the move instead.
|
||||
*
|
||||
* The cut is midnight UTC at the start of the published day, still the
|
||||
* evening before in the Americas. Stopping those few hours early costs
|
||||
* nothing Google Health does not bring back with its own history.
|
||||
*/
|
||||
|
||||
/** The day Google turns the Web API off, as a calendar date. */
|
||||
export const FITBIT_WEB_API_TURNDOWN_DATE = "2026-10-30";
|
||||
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: one instant for every instance, the start of the published day (see the header)
|
||||
const TURNDOWN_AT_MS = Date.parse(`${FITBIT_WEB_API_TURNDOWN_DATE}T00:00:00Z`);
|
||||
|
||||
/** True from the turndown on. */
|
||||
export function isFitbitRetired(now: Date = new Date()): boolean {
|
||||
return now.getTime() >= TURNDOWN_AT_MS;
|
||||
}
|
||||
|
||||
/** What the status envelope publishes on the Fitbit entry. */
|
||||
export interface FitbitRetirement {
|
||||
/** The turndown day, `YYYY-MM-DD`. */
|
||||
endsOn: string;
|
||||
/** The day has come: the connection no longer syncs. */
|
||||
ended: boolean;
|
||||
/** The integration that reads the same devices. */
|
||||
successor: "google-health";
|
||||
}
|
||||
|
||||
export function fitbitRetirement(now: Date = new Date()): FitbitRetirement {
|
||||
return {
|
||||
endsOn: FITBIT_WEB_API_TURNDOWN_DATE,
|
||||
ended: isFitbitRetired(now),
|
||||
successor: "google-health",
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
recomputeBucketsForKeys,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { refreshAccessToken } from "./client";
|
||||
import { isFitbitRetired } from "./retirement";
|
||||
import { getUserFitbitCredentials } from "./credentials";
|
||||
import { FitbitApiError, classifyFitbitError } from "./response-classifier";
|
||||
import type { FitbitClassification } from "./response-classifier";
|
||||
@@ -71,10 +72,15 @@ export interface FitbitTokenInfo {
|
||||
* response ever omits the new refresh token (a malformed reply), keep the stored
|
||||
* one rather than wiping it — a blank refresh token would brick every future
|
||||
* refresh.
|
||||
*
|
||||
* Once Google has turned the Web API off (`./retirement`) there is no token
|
||||
* worth holding: null, with no refresh attempt and nothing on the ledger, so
|
||||
* every caller reads the connection as having nothing to fetch.
|
||||
*/
|
||||
export async function getValidToken(
|
||||
userId: string,
|
||||
): Promise<FitbitTokenInfo | null> {
|
||||
if (isFitbitRetired()) return null;
|
||||
const connection = await prisma.fitbitConnection.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - body/log/weight → WEIGHT (kg)
|
||||
* - body/log/fat → BODY_FAT (%)
|
||||
* - spo2 summary → OXYGEN_SATURATION (%)
|
||||
* - hrv summary → HEART_RATE_VARIABILITY (ms, dailyRmssd)
|
||||
* - hrv summary → HRV_RMSSD (ms, dailyRmssd)
|
||||
* - activities/heart → RESTING_HEART_RATE (bpm)
|
||||
* - br summary → RESPIRATORY_RATE (breaths/min)
|
||||
*
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
import { syncUserMetrics } from "./sync-metrics";
|
||||
import { syncUserSleep } from "./sync-sleep";
|
||||
import { syncUserWorkout } from "./sync-workout";
|
||||
import { isFitbitRetired } from "./retirement";
|
||||
|
||||
/**
|
||||
* Full per-user sync across every Fitbit resource. Drives the hourly poll
|
||||
@@ -49,6 +50,13 @@ export async function syncUserFitbit(
|
||||
userId: string,
|
||||
opts: { fullSync?: boolean } = {},
|
||||
): Promise<{ imported: number; failed: boolean }> {
|
||||
// The Web API is gone (`./retirement`): nothing to fetch, nothing to stamp.
|
||||
// Neither a success (it would paint the connection green) nor a failure (it
|
||||
// would ask for a reconnect that cannot help).
|
||||
if (isFitbitRetired()) {
|
||||
annotate({ action: { name: "fitbit.sync.retired" } });
|
||||
return { imported: 0, failed: false };
|
||||
}
|
||||
if (await isReauthRequired(userId, "fitbit")) {
|
||||
getEvent()?.addWarning(
|
||||
`fitbit sync skipped for ${userId}: parked at error_reauth`,
|
||||
|
||||
@@ -148,7 +148,7 @@ describe("mapOxygenSaturation — daily-oxygen-saturation summary", () => {
|
||||
});
|
||||
|
||||
describe("mapHeartRateVariability — daily-heart-rate-variability summary", () => {
|
||||
it("maps averageHeartRateVariabilityMilliseconds to the HRV slot in ms", () => {
|
||||
it("maps the RMSSD daily average to HRV_RMSSD in ms", () => {
|
||||
const rows = mapHeartRateVariability({
|
||||
dailyHeartRateVariability: {
|
||||
averageHeartRateVariabilityMilliseconds: 42.5,
|
||||
@@ -157,7 +157,7 @@ describe("mapHeartRateVariability — daily-heart-rate-variability summary", ()
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
type: "HRV_RMSSD",
|
||||
unit: "ms",
|
||||
value: 42.5,
|
||||
fieldTag: "2026-06-01:hrv",
|
||||
|
||||
@@ -511,16 +511,16 @@ export function mapHeartRateVariability(
|
||||
tz?: string,
|
||||
): GoogleHealthMappedMeasurement[] {
|
||||
const dt = GOOGLE_HEALTH_DATA_TYPES.heartRateVariability;
|
||||
// The daily field is an unlabelled "average HRV ms". Per the design decision
|
||||
// it lands in the SDNN-lineage `HEART_RATE_VARIABILITY` slot
|
||||
// (Apple-comparable), NOT WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native
|
||||
// estimator). The per-sample type carries explicit RMSSD + SDNN fields —
|
||||
// re-confirm the estimator against live data and revisit if warranted.
|
||||
// Google documents the daily average as RMSSD ("average heart rate
|
||||
// variability calculated using the root mean square of successive
|
||||
// differences"), the same estimator the classic Fitbit API reports as
|
||||
// `dailyRmssd`. It lands in `HRV_RMSSD`, apart from the SDNN
|
||||
// `HEART_RATE_VARIABILITY` series.
|
||||
return mapSimple(
|
||||
point,
|
||||
dt,
|
||||
{
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
type: "HRV_RMSSD",
|
||||
unit: "ms",
|
||||
fieldTag: "hrv",
|
||||
valuePaths: [`${dt.key}.averageHeartRateVariabilityMilliseconds`],
|
||||
|
||||
@@ -81,19 +81,19 @@ The exercise civil bound is derived from the watermark in the USER'S zone.
|
||||
|
||||
Scope: `googlehealth.health_metrics_and_measurements.readonly`.
|
||||
|
||||
| Data type (path) | Payload value read | MeasurementType | Unit | fieldTag | Grain | Note |
|
||||
| ------------------------------------- | --------------------------------------------------------------------- | ------------------------ | ----------- | ------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `weight` | `weight.weightGrams` ÷ 1000 | `WEIGHT` | kg | `weight` | sample | Grams on the wire. Picker ranks a real Withings scale above Google Health. |
|
||||
| `body-fat` | `bodyFat.percentage` | `BODY_FAT` | % | `body_fat` | sample | Union key is camelCase `bodyFat`. |
|
||||
| `daily-oxygen-saturation` | `dailyOxygenSaturation.averagePercentage` | `OXYGEN_SATURATION` | % | `spo2` | daily | The bare `oxygen-saturation` type is per-SAMPLE and rejects a `.date` filter. |
|
||||
| `daily-heart-rate-variability` | `dailyHeartRateVariability.averageHeartRateVariabilityMilliseconds` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. The daily field is an unlabelled "average HRV ms" — re-confirm estimator live. |
|
||||
| `daily-resting-heart-rate` | `dailyRestingHeartRate.beatsPerMinute` (int64 string) | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
|
||||
| `daily-respiratory-rate` | `dailyRespiratoryRate.breathsPerMinute` (number) | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | `respiratory-rate` does NOT exist in the catalogue. Schema is `{date, breathsPerMinute}` — the earlier `dailyRespiratoryRateBpm` leaf never existed. |
|
||||
| `heart-rate` | `heartRate.beatsPerMinute` (int64 string) | `PULSE` | bpm | `hr` | sample | Intraday spot HR; per-minute volume — consider the 14-day-max rollup if PULSE should stay coarse. |
|
||||
| `blood-glucose` | `bloodGlucose.bloodGlucoseMilligramsPerDeciliter` (number) | `BLOOD_GLUCOSE` | mg/dL | `glucose` | sample | Already in the canonical mg/dL storage unit. Meal/timing/specimen enums not stored. |
|
||||
| `core-body-temperature` | `coreBodyTemperature.temperatureCelsius` (number) | `BODY_TEMPERATURE` | celsius | `core_temp` | sample | Core slot — distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE. |
|
||||
| `daily-sleep-temperature-derivations` | `dailySleepTemperatureDerivations.nightlyTemperatureCelsius` (number) | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | ABSOLUTE nightly skin temperature (mean over the night's samples) — the schema refutes the earlier "deviation-only" premise. Baseline/stddev derivations not stored. |
|
||||
| `height` | `height.heightMillimeters` (int64 string) ÷ 10 | `User.heightCm` | cm | — | sample | Profile seed — written ONLY when null; never a Measurement. List order is DESCENDING → the seed picks max(sampleTime) explicitly. The earlier `heightMeters` leaf never existed. |
|
||||
| Data type (path) | Payload value read | MeasurementType | Unit | fieldTag | Grain | Note |
|
||||
| ------------------------------------- | --------------------------------------------------------------------- | -------------------- | ----------- | ------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `weight` | `weight.weightGrams` ÷ 1000 | `WEIGHT` | kg | `weight` | sample | Grams on the wire. Picker ranks a real Withings scale above Google Health. |
|
||||
| `body-fat` | `bodyFat.percentage` | `BODY_FAT` | % | `body_fat` | sample | Union key is camelCase `bodyFat`. |
|
||||
| `daily-oxygen-saturation` | `dailyOxygenSaturation.averagePercentage` | `OXYGEN_SATURATION` | % | `spo2` | daily | The bare `oxygen-saturation` type is per-SAMPLE and rejects a `.date` filter. |
|
||||
| `daily-heart-rate-variability` | `dailyHeartRateVariability.averageHeartRateVariabilityMilliseconds` | `HRV_RMSSD` | ms | `hrv` | daily | Google documents the daily average as RMSSD, so it lands in `HRV_RMSSD`, apart from the SDNN `HEART_RATE_VARIABILITY`. |
|
||||
| `daily-resting-heart-rate` | `dailyRestingHeartRate.beatsPerMinute` (int64 string) | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
|
||||
| `daily-respiratory-rate` | `dailyRespiratoryRate.breathsPerMinute` (number) | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | `respiratory-rate` does NOT exist in the catalogue. Schema is `{date, breathsPerMinute}` — the earlier `dailyRespiratoryRateBpm` leaf never existed. |
|
||||
| `heart-rate` | `heartRate.beatsPerMinute` (int64 string) | `PULSE` | bpm | `hr` | sample | Intraday spot HR; per-minute volume — consider the 14-day-max rollup if PULSE should stay coarse. |
|
||||
| `blood-glucose` | `bloodGlucose.bloodGlucoseMilligramsPerDeciliter` (number) | `BLOOD_GLUCOSE` | mg/dL | `glucose` | sample | Already in the canonical mg/dL storage unit. Meal/timing/specimen enums not stored. |
|
||||
| `core-body-temperature` | `coreBodyTemperature.temperatureCelsius` (number) | `BODY_TEMPERATURE` | celsius | `core_temp` | sample | Core slot — distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE. |
|
||||
| `daily-sleep-temperature-derivations` | `dailySleepTemperatureDerivations.nightlyTemperatureCelsius` (number) | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | ABSOLUTE nightly skin temperature (mean over the night's samples) — the schema refutes the earlier "deviation-only" premise. Baseline/stddev derivations not stored. |
|
||||
| `height` | `height.heightMillimeters` (int64 string) ÷ 10 | `User.heightCm` | cm | — | sample | Profile seed — written ONLY when null; never a Measurement. List order is DESCENDING → the seed picks max(sampleTime) explicitly. The earlier `heightMeters` leaf never existed. |
|
||||
|
||||
Daily-summary `date` fields are `{year,month,day}` objects, anchored at
|
||||
UTC-midday so a timezone shift can't roll the civil day. Every value passes a
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - weight → WEIGHT
|
||||
* - body-fat → BODY_FAT
|
||||
* - daily-oxygen-saturation → OXYGEN_SATURATION
|
||||
* - daily-heart-rate-variability → HEART_RATE_VARIABILITY (SDNN slot; see mapper)
|
||||
* - daily-heart-rate-variability → HRV_RMSSD (RMSSD; see mapper)
|
||||
* - daily-resting-heart-rate → RESTING_HEART_RATE
|
||||
* - daily-respiratory-rate → RESPIRATORY_RATE
|
||||
* - heart-rate (intraday) → PULSE
|
||||
@@ -117,7 +117,7 @@ const METRIC_RESOURCES: MetricResource[] = [
|
||||
dataType: GOOGLE_HEALTH_DATA_TYPES.heartRateVariability,
|
||||
map: mapHeartRateVariability,
|
||||
verb: "fetchHrv",
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
type: "HRV_RMSSD",
|
||||
},
|
||||
{
|
||||
dataType: GOOGLE_HEALTH_DATA_TYPES.restingHeartRate,
|
||||
|
||||
@@ -40,9 +40,8 @@ export const MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS: Record<
|
||||
MCP: "settings.sections.sources.sourceLabels.MCP",
|
||||
GOOGLE_HEALTH: "settings.sections.sources.sourceLabels.GOOGLE_HEALTH",
|
||||
STRAVA: "settings.sections.sources.sourceLabels.STRAVA",
|
||||
// v1.38.x — readings pushed in under a `measurements:write` token.
|
||||
// Never offered in the ladder (it is on no default priority list), but
|
||||
// the map is keyed by the enum, so the entry is not optional.
|
||||
// v1.38.x — readings pushed in under a `measurements:write` token. On the
|
||||
// default ladders right after MANUAL since v1.43.11.
|
||||
EXTERNAL: "settings.sections.sources.sourceLabels.EXTERNAL",
|
||||
HEALTH_CONNECT: "settings.sections.sources.sourceLabels.HEALTH_CONNECT",
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
computeIllnessCorrelation,
|
||||
dayDiff,
|
||||
FUNCTIONAL_IMPACT_RETURN_KEY,
|
||||
ILLNESS_SCAN_TYPES,
|
||||
isAdverseDeviation,
|
||||
MIN_BASELINE_DAYS,
|
||||
MIN_EPISODE_COVERAGE_DAYS,
|
||||
@@ -803,6 +804,12 @@ describe("isAdverseDeviation", () => {
|
||||
expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "above")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a drop in the nightly RMSSD series as adverse, like SDNN", () => {
|
||||
expect(ILLNESS_SCAN_TYPES).toContain("HRV_RMSSD");
|
||||
expect(isAdverseDeviation("HRV_RMSSD", "below")).toBe(true);
|
||||
expect(isAdverseDeviation("HRV_RMSSD", "above")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats neutral-direction vitals (WEIGHT) as never adverse", () => {
|
||||
expect(isAdverseDeviation("WEIGHT", "above")).toBe(false);
|
||||
expect(isAdverseDeviation("WEIGHT", "below")).toBe(false);
|
||||
|
||||
@@ -118,6 +118,7 @@ export const ADVERSE_DIRECTION: Partial<
|
||||
BLOOD_PRESSURE_SYS: "up",
|
||||
BLOOD_PRESSURE_DIA: "up",
|
||||
HEART_RATE_VARIABILITY: "down",
|
||||
HRV_RMSSD: "down",
|
||||
RECOVERY_SCORE: "down",
|
||||
OXYGEN_SATURATION: "down",
|
||||
};
|
||||
@@ -130,6 +131,9 @@ export const ADVERSE_DIRECTION: Partial<
|
||||
export const ILLNESS_SCAN_TYPES: MeasurementType[] = [
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
// RMSSD, scanned on its own baseline: a nightly strap or ring reading drops
|
||||
// during illness the same way SDNN does.
|
||||
"HRV_RMSSD",
|
||||
"RECOVERY_SCORE",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_TEMPERATURE",
|
||||
|
||||
@@ -127,6 +127,21 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => {
|
||||
expect(keys).toContain("CUSTOM_METRIC:abc");
|
||||
});
|
||||
|
||||
it("scans the nightly RMSSD series as an outcome owned by recovery", async () => {
|
||||
const on = await assembleDiscoveryMatrix("u1", OPTS);
|
||||
expect(
|
||||
on.series.find((s) => s.key === "HRV_RMSSD" && s.role === "outcome"),
|
||||
).toBeDefined();
|
||||
const off = await assembleDiscoveryMatrix("u1", {
|
||||
...OPTS,
|
||||
modules: { recovery: false },
|
||||
});
|
||||
const keys = keysOf(off.series);
|
||||
expect(keys).not.toContain("HRV_RMSSD");
|
||||
// SDNN is a plain vital and stays.
|
||||
expect(keys).toContain("HEART_RATE_VARIABILITY");
|
||||
});
|
||||
|
||||
it("does not read the weather at all with the environment module off", async () => {
|
||||
const read = vi.mocked(fetchEnvironmentSeries);
|
||||
read.mockClear();
|
||||
|
||||
@@ -662,6 +662,12 @@ export const DISCOVERY_BEHAVIOURS = [
|
||||
export const DISCOVERY_OUTCOMES = [
|
||||
"SLEEP_DURATION",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
// The nightly RMSSD series (WHOOP, Oura, Polar, Fitbit, Google Health) as
|
||||
// its own outcome. It is not merged into the SDNN channel: the estimators
|
||||
// differ in scale, so one series of both would correlate the switch between
|
||||
// devices rather than the body. Neither HRV channel is a behaviour, so the
|
||||
// two never pair with each other.
|
||||
"HRV_RMSSD",
|
||||
"RESTING_HEART_RATE",
|
||||
"WEIGHT",
|
||||
// v1.22 — blood pressure as an OUTCOME (it was behaviour-only). This makes
|
||||
|
||||
@@ -46,6 +46,7 @@ import { readOuraScopeDenied } from "@/lib/oura/credentials";
|
||||
import { ouraScopeReconnectList } from "@/lib/oura/scope";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
export type ConnectionHealthMap = Record<
|
||||
ConnectionKey,
|
||||
@@ -234,7 +235,10 @@ export async function loadConnectionFacts(
|
||||
const connected: Record<ConnectionKey, boolean> = {
|
||||
withings: !!withingsConn,
|
||||
whoop: !!whoopConn?.whoopUserId,
|
||||
fitbit: !!fitbitConn,
|
||||
// Once Google has turned the Fitbit Web API off the row stays (it carries
|
||||
// the imported history) but nothing will sync through it: no health to
|
||||
// judge and nothing for the watchdog to call silent.
|
||||
fitbit: !!fitbitConn && !isFitbitRetired(now),
|
||||
"google-health": !!googleHealthConn,
|
||||
polar: !!dbUser?.polarAccessTokenEncrypted,
|
||||
oura: !!dbUser?.ouraAccessTokenEncrypted,
|
||||
|
||||
@@ -49,6 +49,13 @@ import { annotate } from "@/lib/logging/context";
|
||||
export interface WalkPage<C, T> {
|
||||
items: readonly T[];
|
||||
next: C | null | undefined;
|
||||
/**
|
||||
* The provider refused to go on for now (a rate limit) with history left.
|
||||
* The walk ends here with what it read, reported `truncated` like a walk
|
||||
* cut off at its ceiling, so a backfill resumes later instead of stamping
|
||||
* a history it did not finish.
|
||||
*/
|
||||
interrupted?: boolean;
|
||||
}
|
||||
|
||||
/** What the walk came to. */
|
||||
@@ -78,6 +85,9 @@ export async function walkPages<C, T>(
|
||||
for (let page = 0; page < maxPages; page += 1) {
|
||||
const result = await fetchPage(cursor, page);
|
||||
items.push(...result.items);
|
||||
if (result.interrupted) {
|
||||
return truncated(label, items, page + 1, "interrupted");
|
||||
}
|
||||
const next = result.next;
|
||||
if (next === null || next === undefined || next === "") {
|
||||
return { items, pages: page + 1, truncated: false };
|
||||
@@ -93,7 +103,7 @@ function truncated<T>(
|
||||
label: string,
|
||||
items: T[],
|
||||
pages: number,
|
||||
reason: "ceiling" | "repeat",
|
||||
reason: "ceiling" | "repeat" | "interrupted",
|
||||
): PageWalk<T> {
|
||||
annotate({
|
||||
action: { name: "integration.page_walk.truncated" },
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
enqueueBootTimeFitbitBackfill,
|
||||
runFitbitBackfillForUser,
|
||||
} from "../fitbit-backfill";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -146,3 +147,28 @@ describe("runFitbitBackfillForUser — a walk that reached its ceiling", () => {
|
||||
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("after Google turned the Fitbit Web API off", () => {
|
||||
it("enqueues no backfill and runs a queued one without walking or stamping", async () => {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(true);
|
||||
try {
|
||||
prismaMock.fitbitConnection.findMany.mockResolvedValue([
|
||||
{ userId: "u1" },
|
||||
]);
|
||||
await expect(enqueueBootTimeFitbitBackfill()).resolves.toEqual({
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: null,
|
||||
});
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
|
||||
await expect(runFitbitBackfillForUser("u1")).resolves.toEqual({
|
||||
imported: 0,
|
||||
});
|
||||
expect(syncUserFitbit).not.toHaveBeenCalled();
|
||||
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
enqueueBootTimeFitbitSleepRepair,
|
||||
runFitbitSleepRepairForUser,
|
||||
} from "../fitbit-sleep-repair";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -231,3 +232,29 @@ describe("runFitbitSleepRepairForUser", () => {
|
||||
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("after Google turned the Fitbit Web API off", () => {
|
||||
it("enqueues no repair and runs a queued one without reading or stamping", async () => {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(true);
|
||||
try {
|
||||
prismaMock.fitbitConnection.findMany.mockResolvedValue([
|
||||
{ userId: "u1" },
|
||||
]);
|
||||
await expect(enqueueBootTimeFitbitSleepRepair()).resolves.toEqual({
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: null,
|
||||
});
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
|
||||
await expect(runFitbitSleepRepairForUser("u1")).resolves.toEqual({
|
||||
imported: 0,
|
||||
removed: 0,
|
||||
});
|
||||
expect(syncUserSleep).not.toHaveBeenCalled();
|
||||
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The hourly Fitbit cron after Google turned the Web API off: it reads no
|
||||
* connection, runs no cohort, and finishes as a clean no-op, so the shutdown
|
||||
* cannot turn into an hourly storm of failed polls.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { findMany, runFitbitPollCohort } = vi.hoisted(() => ({
|
||||
findMany: vi.fn(async () => [{ userId: "u1" }]),
|
||||
runFitbitPollCohort: vi.fn(async () => ({
|
||||
usersSynced: 1,
|
||||
measurementsImported: 3,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/reminder/shared", () => ({
|
||||
getWorkerPrisma: () => ({ fitbitConnection: { findMany } }),
|
||||
}));
|
||||
vi.mock("@/lib/fitbit/sync", () => ({ runFitbitPollCohort }));
|
||||
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
|
||||
enqueueReminderSatisfy: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
import { handleFitbitSync } from "../reminder/fitbit-sync";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
describe("handleFitbitSync", () => {
|
||||
it("polls the cohort while the Web API is up", async () => {
|
||||
await handleFitbitSync([]);
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
expect(runFitbitPollCohort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does nothing at all after the turndown", async () => {
|
||||
findMany.mockClear();
|
||||
runFitbitPollCohort.mockClear();
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(true);
|
||||
try {
|
||||
const outcome = await handleFitbitSync([]);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
expect(runFitbitPollCohort).not.toHaveBeenCalled();
|
||||
expect(outcome).toMatchObject({ ok: true, did: { total: 0 } });
|
||||
} finally {
|
||||
vi.mocked(isFitbitRetired).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
withPageWalkLedger,
|
||||
} from "@/lib/integrations/page-walk";
|
||||
import { syncUserFitbit } from "@/lib/fitbit/sync";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
export const FITBIT_BACKFILL_QUEUE = "fitbit-backfill";
|
||||
|
||||
@@ -58,6 +59,17 @@ export interface FitbitBackfillPayload {
|
||||
export async function runFitbitBackfillForUser(
|
||||
userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
// A job queued before the turndown: the history it would walk is gone with
|
||||
// the API. Returns without stamping, so the marker never claims a walk.
|
||||
if (isFitbitRetired()) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "fitbit.backfill.skipped_retired",
|
||||
details: { imported: 0 },
|
||||
},
|
||||
});
|
||||
return { imported: 0 };
|
||||
}
|
||||
if (await isReauthRequired(userId, "fitbit")) {
|
||||
annotate({
|
||||
action: {
|
||||
@@ -133,7 +145,7 @@ export async function enqueueBootTimeFitbitBackfill(
|
||||
error: string | null;
|
||||
}> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
if (!boss || isFitbitRetired()) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
runWithFitbitHardFailLedger,
|
||||
} from "@/lib/fitbit/sync-core";
|
||||
import { syncUserSleep } from "@/lib/fitbit/sync-sleep";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
export const FITBIT_SLEEP_REPAIR_QUEUE = "fitbit-sleep-repair";
|
||||
|
||||
@@ -82,6 +83,16 @@ export interface FitbitSleepRepairPayload {
|
||||
export async function runFitbitSleepRepairForUser(
|
||||
userId: string,
|
||||
): Promise<{ imported: number; removed: number }> {
|
||||
// Nothing left to re-read once the Web API is gone; no stamp either.
|
||||
if (isFitbitRetired()) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "fitbit.sleepRepair.skipped_retired",
|
||||
details: { imported: 0, removed: 0 },
|
||||
},
|
||||
});
|
||||
return { imported: 0, removed: 0 };
|
||||
}
|
||||
if (await isReauthRequired(userId, "fitbit")) {
|
||||
annotate({
|
||||
action: {
|
||||
@@ -155,7 +166,7 @@ export async function enqueueBootTimeFitbitSleepRepair(
|
||||
error: string | null;
|
||||
}> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
if (!boss || isFitbitRetired()) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -17,20 +17,30 @@ import { syncUserPolarLegs } from "../polar-sync";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
syncUserPolar.mockResolvedValue({ imported: 0, failed: false });
|
||||
syncUserPolar.mockResolvedValue({
|
||||
imported: 0,
|
||||
failed: false,
|
||||
tokenProven: true,
|
||||
});
|
||||
syncUserPolarWorkouts.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("syncUserPolarLegs — composite independence on the shared polar ledger", () => {
|
||||
it("runs both legs and returns the summed count when both pass", async () => {
|
||||
syncUserPolar.mockResolvedValue({ imported: 5, failed: false });
|
||||
syncUserPolar.mockResolvedValue({
|
||||
imported: 5,
|
||||
failed: false,
|
||||
tokenProven: true,
|
||||
});
|
||||
syncUserPolarWorkouts.mockResolvedValue(2);
|
||||
await expect(syncUserPolarLegs("u1")).resolves.toEqual({
|
||||
imported: 7,
|
||||
failed: false,
|
||||
});
|
||||
expect(syncUserPolar).toHaveBeenCalledWith("u1");
|
||||
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
|
||||
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", {
|
||||
tokenProven: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("sums both counts without throwing when the vitals leg returns a partial-failure count", async () => {
|
||||
@@ -38,7 +48,11 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
|
||||
// its healthy-collections import count rather than throwing. The wrapper
|
||||
// must sum it with the workout leg and complete cleanly — a partial vitals
|
||||
// failure never starves the user's success accounting at the cohort level.
|
||||
syncUserPolar.mockResolvedValue({ imported: 4, failed: true });
|
||||
syncUserPolar.mockResolvedValue({
|
||||
imported: 4,
|
||||
failed: true,
|
||||
tokenProven: true,
|
||||
});
|
||||
syncUserPolarWorkouts.mockResolvedValue(2);
|
||||
// The partial is carried, not swallowed: the wrapper completes with the
|
||||
// summed count AND says a leg did not settle, so the card cannot paint a
|
||||
@@ -48,7 +62,9 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
|
||||
failed: true,
|
||||
});
|
||||
expect(syncUserPolar).toHaveBeenCalledWith("u1");
|
||||
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
|
||||
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", {
|
||||
tokenProven: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("still attempts the workout leg when the vitals leg fails (vice-versa isolation)", async () => {
|
||||
@@ -57,11 +73,17 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
|
||||
// The vitals error is rethrown (attributable), but only AFTER the workout
|
||||
// leg was attempted — a vitals failure never starves the workout leg.
|
||||
await expect(syncUserPolarLegs("u1")).rejects.toThrow("vitals down");
|
||||
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
|
||||
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", {
|
||||
tokenProven: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the vitals leg's result intact when the workout leg fails", async () => {
|
||||
syncUserPolar.mockResolvedValue({ imported: 4, failed: false });
|
||||
syncUserPolar.mockResolvedValue({
|
||||
imported: 4,
|
||||
failed: false,
|
||||
tokenProven: true,
|
||||
});
|
||||
syncUserPolarWorkouts.mockRejectedValue(new Error("workouts down"));
|
||||
// The vitals leg ran to completion (its own success already recorded on
|
||||
// the shared ledger); the workout error is surfaced without undoing it.
|
||||
@@ -73,6 +95,8 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
|
||||
syncUserPolar.mockRejectedValue(new Error("vitals first"));
|
||||
syncUserPolarWorkouts.mockRejectedValue(new Error("workouts second"));
|
||||
await expect(syncUserPolarLegs("u1")).rejects.toThrow("vitals first");
|
||||
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
|
||||
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", {
|
||||
tokenProven: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { runFitbitPollCohort } from "@/lib/fitbit/sync";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { cleanupExpiredFitbitOAuthStates } from "@/lib/jobs/fitbit-oauth-state-cleanup";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
/**
|
||||
* v1.12.0 — Fitbit poll-sync payload. Poll-only (no webhook at launch): the
|
||||
@@ -28,6 +29,20 @@ export async function handleFitbitSync(
|
||||
jobs: Job<FitbitSyncPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
return withBackgroundEvent("job.fitbit_sync", async (evt) => {
|
||||
// Google turned the Web API off (`@/lib/fitbit/retirement`): the cron
|
||||
// tick reads no connection and calls nothing.
|
||||
if (isFitbitRetired()) {
|
||||
evt.setBackground({
|
||||
task_name: "job.fitbit_sync",
|
||||
result: { retired: true },
|
||||
});
|
||||
return jobDone({
|
||||
total: 0,
|
||||
users_synced: 0,
|
||||
users_failed: 0,
|
||||
measurements_imported: 0,
|
||||
});
|
||||
}
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
const targets: Array<{ userId: string }> = [];
|
||||
|
||||
@@ -23,8 +23,9 @@ export type PolarSyncPayload = PollCohortPayload;
|
||||
/**
|
||||
* Run both Polar legs for one user with INDEPENDENT attribution on the shared
|
||||
* `polar` ledger. Sequentially awaited (not `Promise.all`) so a workouts error
|
||||
* stays attributable. Each leg records its own failure; the vitals leg owns the
|
||||
* single success write. A failure in either leg is isolated — both legs are
|
||||
* stays attributable, and so the vitals leg can tell the workouts leg whether
|
||||
* the token just worked (a 403 on exercises is then a per-type refusal). Each
|
||||
* leg records its own failure and its own success. A failure in either leg is isolated — both legs are
|
||||
* always attempted, so a workouts-leg failure can never mask a vitals success
|
||||
* and vice versa — then the FIRST error is rethrown so the cohort handler warns
|
||||
* and skips this user's success accounting without losing the error.
|
||||
@@ -45,18 +46,20 @@ export async function syncUserPolarLegs(
|
||||
// it has to be carried explicitly — otherwise a run in which four of five
|
||||
// collections failed reads exactly like a clean one.
|
||||
let partial = false;
|
||||
let tokenProven = false;
|
||||
|
||||
try {
|
||||
const vitals = await syncUserPolar(userId);
|
||||
total += vitals.imported;
|
||||
partial = vitals.failed;
|
||||
tokenProven = vitals.tokenProven;
|
||||
} catch (err) {
|
||||
firstError = err;
|
||||
hadError = true;
|
||||
}
|
||||
|
||||
try {
|
||||
total += await syncUserPolarWorkouts(userId);
|
||||
total += await syncUserPolarWorkouts(userId, { tokenProven });
|
||||
} catch (err) {
|
||||
if (!hadError) {
|
||||
firstError = err;
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function runStravaBackfillForUser(
|
||||
const { result, truncated } = await withPageWalkLedger(() =>
|
||||
syncStravaHistory(userId, before),
|
||||
);
|
||||
const { imported, oldestStart } = result;
|
||||
const { imported, oldestStart, retryAfterSeconds } = result;
|
||||
// A failure has already thrown; what is left to decide is whether the walk
|
||||
// reached the beginning of the history.
|
||||
if (backfillVerdict({ failed: false, truncated }) === "incomplete") {
|
||||
@@ -111,7 +111,18 @@ export async function runStravaBackfillForUser(
|
||||
data: { stravaBackfillResumeBefore: resumeBefore },
|
||||
});
|
||||
}
|
||||
const continued = progressed && (await sendNextRun(userId, slice));
|
||||
// A run Strava's rate limit ended goes on once the window it named has
|
||||
// reset, progress or not: the limit is the app's, and passes.
|
||||
const continued =
|
||||
(progressed || retryAfterSeconds !== undefined) &&
|
||||
(await sendNextRun(
|
||||
userId,
|
||||
slice,
|
||||
Math.max(
|
||||
STRAVA_BACKFILL_CONTINUATION_DELAY_SECONDS,
|
||||
retryAfterSeconds ?? 0,
|
||||
),
|
||||
));
|
||||
annotate({
|
||||
action: {
|
||||
name: "strava.backfill.truncated",
|
||||
@@ -143,7 +154,11 @@ export async function runStravaBackfillForUser(
|
||||
return { imported, continued: false };
|
||||
}
|
||||
|
||||
async function sendNextRun(userId: string, slice: number): Promise<boolean> {
|
||||
async function sendNextRun(
|
||||
userId: string,
|
||||
slice: number,
|
||||
delaySeconds: number,
|
||||
): Promise<boolean> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return false;
|
||||
const sent = await enqueueIntegrationBackfillAdmission(
|
||||
@@ -152,7 +167,7 @@ async function sendNextRun(userId: string, slice: number): Promise<boolean> {
|
||||
kind: "strava-backfill",
|
||||
data: { userId, enqueuedAt: new Date().toISOString(), slice: slice + 1 },
|
||||
},
|
||||
STRAVA_BACKFILL_CONTINUATION_DELAY_SECONDS,
|
||||
delaySeconds,
|
||||
);
|
||||
return Boolean(sent);
|
||||
}
|
||||
|
||||
@@ -212,6 +212,8 @@ const STATIC_SURFACE_MODULE = {
|
||||
"correlation:SLEEP_DURATION": "sleep",
|
||||
"correlation:BLOOD_GLUCOSE": "glucose",
|
||||
"correlation:RESTING_HEART_RATE": "recovery",
|
||||
// The RMSSD series is the recovery module's (see `summary:HRV_RMSSD`).
|
||||
"correlation:HRV_RMSSD": "recovery",
|
||||
"correlation:MEDICATION_COMPLIANCE": "medications",
|
||||
// Symptom burden is read from the illness journal.
|
||||
"correlation:SYMPTOM_SEVERITY": "illness",
|
||||
|
||||
@@ -486,6 +486,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"oidc_only",
|
||||
"private_origin_not_approved",
|
||||
"private_origin_not_grantable",
|
||||
"provider_retired",
|
||||
"rate_limited_self",
|
||||
"sync_in_progress",
|
||||
"too_many_rows",
|
||||
|
||||
@@ -1151,10 +1151,29 @@ const integrationEnvelopeEntry = z
|
||||
),
|
||||
hasToken: z.boolean().optional().describe("Nightscout only."),
|
||||
allowPrivateHost: z.boolean().optional().describe("Nightscout only."),
|
||||
retirement: z
|
||||
.object({
|
||||
endsOn: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe("The day Google turns the legacy Fitbit Web API off."),
|
||||
ended: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"The day has come: the connection no longer syncs, its imported data stays, and `health` is null.",
|
||||
),
|
||||
successor: z
|
||||
.enum(["google-health"])
|
||||
.describe(
|
||||
"The integration that reads the same Fitbit and Pixel devices. Moving needs a fresh consent; the Fitbit grant cannot be carried over.",
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe("Fitbit only. The end of the legacy Fitbit Web API."),
|
||||
health: connectionHealth
|
||||
.nullable()
|
||||
.describe(
|
||||
"The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.",
|
||||
"The unified connection state, null when the provider is not connected (and for Fitbit once `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
@@ -2020,6 +2039,11 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self` for both; the message distinguishes them.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"410": {
|
||||
description:
|
||||
"Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is fetched. `meta.errorCode` = `provider_retired`. The data already imported stays; Google Health reads the same devices.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2663,6 +2687,11 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
...probeNotConfigured422,
|
||||
...selfRateLimited429,
|
||||
"410": {
|
||||
description:
|
||||
"Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is probed. `meta.errorCode` = `provider_retired`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
getPolarConnection,
|
||||
fetchExercises,
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
} = vi.hoisted(() => ({
|
||||
createManyAndReturn: vi.fn(),
|
||||
update: vi.fn(),
|
||||
@@ -16,6 +17,7 @@ const {
|
||||
getPolarConnection: vi.fn(),
|
||||
fetchExercises: vi.fn(),
|
||||
recordSyncFailure: vi.fn(async () => {}),
|
||||
recordSyncSuccess: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -32,6 +34,7 @@ vi.mock("../credentials", () => ({ getPolarConnection }));
|
||||
vi.mock("@/lib/integrations/status", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/integrations/status")>()),
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
}));
|
||||
vi.mock("../client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client")>();
|
||||
@@ -181,9 +184,9 @@ describe("syncUserPolarWorkouts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Assumption #4: a 403 scope gap is a soft/recoverable ledger error, not a
|
||||
// hard crash — recorded as reauth_required and rethrown for cohort isolation.
|
||||
it("records a 403 scope error as a soft reauth_required failure", async () => {
|
||||
// Without a read that proved the token, a 403 may be the account-wide
|
||||
// consent gap: recorded as reauth_required and rethrown for cohort isolation.
|
||||
it("records a 403 as a reauth_required failure when nothing proved the token", async () => {
|
||||
fetchExercises.mockRejectedValue(
|
||||
new PolarApiError({
|
||||
verb: "fetchExercises",
|
||||
@@ -204,11 +207,54 @@ describe("syncUserPolarWorkouts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("never records success on the shared ledger (vitals leg owns the single success write)", async () => {
|
||||
const status = await import("@/lib/integrations/status");
|
||||
const spy = vi.spyOn(status, "recordSyncSuccess");
|
||||
it("reads a 403 as a refused data type once the vitals leg proved the token", async () => {
|
||||
fetchExercises.mockRejectedValue(
|
||||
new PolarApiError({
|
||||
verb: "fetchExercises",
|
||||
classification: "reauth_required",
|
||||
httpStatus: 403,
|
||||
reason: "http_403",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
syncUserPolarWorkouts("u1", { tokenProven: true }),
|
||||
).resolves.toBe(0);
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("u1", "polar", {
|
||||
leg: "workouts",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a 401 a failure even after the vitals leg proved the token", async () => {
|
||||
fetchExercises.mockRejectedValue(
|
||||
new PolarApiError({
|
||||
verb: "fetchExercises",
|
||||
classification: "reauth_required",
|
||||
httpStatus: 401,
|
||||
reason: "http_401",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
syncUserPolarWorkouts("u1", { tokenProven: true }),
|
||||
).rejects.toBeInstanceOf(PolarApiError);
|
||||
expect(recordSyncFailure).toHaveBeenCalledTimes(1);
|
||||
expect(recordSyncSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A leg-scoped success clears only its own leg, so this leg has to write
|
||||
// its own: left to the vitals leg, one workouts failure stayed on the row
|
||||
// for good.
|
||||
it("records its own leg's success after a clean read", async () => {
|
||||
fetchExercises.mockResolvedValue([]);
|
||||
await syncUserPolarWorkouts("u1");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("u1", "polar", {
|
||||
leg: "workouts",
|
||||
});
|
||||
});
|
||||
|
||||
it("records no success when the read failed", async () => {
|
||||
fetchExercises.mockRejectedValue(new Error("boom"));
|
||||
await expect(syncUserPolarWorkouts("u1")).rejects.toThrow("boom");
|
||||
expect(recordSyncSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -164,7 +164,11 @@ function createdRows() {
|
||||
describe("syncUserPolar", () => {
|
||||
it("no-ops cleanly for an unconnected user", async () => {
|
||||
getConnMock.mockResolvedValue(null);
|
||||
expect(await syncUserPolar("u1")).toEqual({ imported: 0, failed: false });
|
||||
expect(await syncUserPolar("u1")).toEqual({
|
||||
imported: 0,
|
||||
failed: false,
|
||||
tokenProven: false,
|
||||
});
|
||||
expect(createManyAndReturnMock).not.toHaveBeenCalled();
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -370,13 +374,13 @@ describe("syncUserPolar", () => {
|
||||
|
||||
it("isolates one dead collection — imports the healthy four and records one named failure", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
// SpO2 403s (no sensor / consent), the other four return one row each.
|
||||
// SpO2 fails upstream, the other four return one row each.
|
||||
fetchSpo2Mock.mockRejectedValue(
|
||||
new PolarApiError({
|
||||
verb: "fetchSpo2",
|
||||
classification: "reauth_required",
|
||||
httpStatus: 403,
|
||||
reason: "http_403",
|
||||
classification: "transient",
|
||||
httpStatus: 503,
|
||||
reason: "http_503",
|
||||
}),
|
||||
);
|
||||
fetchRechargesMock.mockResolvedValue([
|
||||
@@ -402,6 +406,7 @@ describe("syncUserPolar", () => {
|
||||
|
||||
// The partial rides back with the count rather than being narrowed away.
|
||||
expect(result.failed).toBe(true);
|
||||
expect(result.tokenProven).toBe(true);
|
||||
// The healthy four still wrote their rows — the source is not blanked.
|
||||
const written = createdRows().map((row) => row.type);
|
||||
expect(written).toContain("RECOVERY_SCORE");
|
||||
@@ -410,19 +415,74 @@ describe("syncUserPolar", () => {
|
||||
expect(written).toContain("CARDIO_LOAD");
|
||||
expect(written).not.toContain("OXYGEN_SATURATION");
|
||||
expect(imported).toBeGreaterThan(0);
|
||||
// Exactly one failure, classified off the 403, naming spo2. No success.
|
||||
// Exactly one failure, naming spo2. No success.
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
integration: "polar",
|
||||
kind: "reauth_required",
|
||||
errorCode: "403",
|
||||
kind: "transient",
|
||||
errorCode: "503",
|
||||
message: expect.stringContaining("spo2"),
|
||||
}),
|
||||
);
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads a 403 beside a healthy sibling as a refused data type, not a dead token", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
// An account without the SpO2 sensor or the consent for it: the same token
|
||||
// reads the other collections fine.
|
||||
fetchSpo2Mock.mockRejectedValue(
|
||||
new PolarApiError({
|
||||
verb: "fetchSpo2",
|
||||
classification: "reauth_required",
|
||||
httpStatus: 403,
|
||||
reason: "http_403",
|
||||
}),
|
||||
);
|
||||
fetchCardioLoadsMock.mockResolvedValue([
|
||||
{ date: "2026-06-10", cardio_load: 55 },
|
||||
]);
|
||||
|
||||
const result = await syncUserPolar("u1");
|
||||
|
||||
expect(result).toMatchObject({ failed: false, tokenProven: true });
|
||||
expect(createdRows().map((row) => row.type)).toContain("CARDIO_LOAD");
|
||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar", {
|
||||
leg: "vitals",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a 403 on every collection a reauth_required failure", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
for (const mock of [
|
||||
fetchRechargesMock,
|
||||
fetchSleepsMock,
|
||||
fetchActivitiesMock,
|
||||
fetchCardioLoadsMock,
|
||||
fetchSpo2Mock,
|
||||
]) {
|
||||
mock.mockRejectedValue(
|
||||
new PolarApiError({
|
||||
verb: "fetch",
|
||||
classification: "reauth_required",
|
||||
httpStatus: 403,
|
||||
reason: "http_403",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await syncUserPolar("u1");
|
||||
|
||||
expect(result).toMatchObject({ failed: true, tokenProven: false });
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "reauth_required", errorCode: "403" }),
|
||||
);
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("isolates a throwing mapper — one malformed record does not blank siblings", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
// A malformed recharge record whose mapper throws on first field access
|
||||
@@ -534,9 +594,9 @@ describe("syncUserPolar", () => {
|
||||
mockByName[name]!.mockRejectedValue(
|
||||
new PolarApiError({
|
||||
verb: name,
|
||||
classification: "reauth_required",
|
||||
httpStatus: 403,
|
||||
reason: "http_403",
|
||||
classification: "persistent",
|
||||
httpStatus: 404,
|
||||
reason: "http_404",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -60,6 +60,17 @@ export function isInvalidGrant(err: unknown): boolean {
|
||||
return isOAuthInvalidGrant(err, "Polar");
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a collection read Polar refused with 403. AccessLink documents its
|
||||
* 403 as "User has not accepted all mandatory consents": the token is good,
|
||||
* the account does not share this one data type (or has nothing behind it).
|
||||
* Only meaningful next to a read that succeeded with the same token — a 403 on
|
||||
* every collection is the account-wide consent gap, and stays a failure.
|
||||
*/
|
||||
export function isPolarCollectionRefusal(err: unknown): boolean {
|
||||
return err instanceof PolarApiError && err.httpStatus === 403;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the classification from any caught error. A non-`PolarApiError` defaults
|
||||
* to `transient` so a plain `Error` retries rather than disabling the
|
||||
|
||||
@@ -21,12 +21,13 @@
|
||||
* only write and it is idempotent — no cursor, no backfill queue (the API caps
|
||||
* history at 30 days, so first-connect backfill IS the first regular tick).
|
||||
*
|
||||
* Ledger: workouts and vitals share the single `polar` integration ledger. This
|
||||
* leg records only its OWN FAILURE (and rethrows so the cohort handler can
|
||||
* attribute it); the single success write stays the vitals leg's job so a fully
|
||||
* successful pass records success exactly once. A workouts-leg failure records a
|
||||
* transient/reauth failure on the shared ledger but never touches the vitals
|
||||
* success timestamp (`recordSyncFailure` leaves `lastSuccessAt` intact).
|
||||
* Ledger: workouts and vitals share the single `polar` integration ledger, each
|
||||
* under its own leg. This leg records its own failure (and rethrows so the
|
||||
* cohort handler can attribute it) and its own success. The success used to be
|
||||
* left to the vitals leg, but a leg-scoped success clears only its own leg, so
|
||||
* a workouts failure stayed on the row for good once recorded: every later
|
||||
* vitals success saw the workouts leg still failing and kept the connection in
|
||||
* error, however healthy the exercises read had become.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
|
||||
@@ -35,11 +36,16 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
markSyncFailureRecorded,
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
toFailureKind,
|
||||
} from "@/lib/integrations/status";
|
||||
import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client";
|
||||
import { getPolarConnection } from "./credentials";
|
||||
import { PolarApiError, classifyPolarError } from "./response-classifier";
|
||||
import {
|
||||
PolarApiError,
|
||||
classifyPolarError,
|
||||
isPolarCollectionRefusal,
|
||||
} from "./response-classifier";
|
||||
|
||||
const POLAR_LEG_WORKOUTS = "workouts";
|
||||
|
||||
@@ -70,13 +76,16 @@ async function recordPolarWorkoutFailure(
|
||||
* written. A user with no Polar connection is a clean no-op (returns 0, touches
|
||||
* no status row).
|
||||
*
|
||||
* A 403 on the exercises read (a scope gap under `accesslink.read_all`) is
|
||||
* classified through the shared response classifier as `reauth_required` — a
|
||||
* SOFT/recoverable integration error surfaced on the status ledger, NOT a hard
|
||||
* crash: it is recorded and rethrown so the composite cohort pass isolates this
|
||||
* user and the vitals leg's own result is untouched.
|
||||
* A 403 on the exercises read is a refusal when the vitals leg proved the token
|
||||
* in the same run (`tokenProven`): the account does not share exercises, which
|
||||
* no reconnect changes, so the leg reads nothing and stays healthy. Without
|
||||
* that proof the 403 is classified `reauth_required` as before, recorded, and
|
||||
* rethrown so the composite cohort pass isolates this user.
|
||||
*/
|
||||
export async function syncUserPolarWorkouts(userId: string): Promise<number> {
|
||||
export async function syncUserPolarWorkouts(
|
||||
userId: string,
|
||||
opts: { tokenProven?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const conn = await getPolarConnection(userId);
|
||||
if (!conn) return 0;
|
||||
|
||||
@@ -91,6 +100,11 @@ export async function syncUserPolarWorkouts(userId: string): Promise<number> {
|
||||
if (row) rows.push(row);
|
||||
}
|
||||
} catch (err) {
|
||||
if (opts.tokenProven && isPolarCollectionRefusal(err)) {
|
||||
annotate({ meta: { polar_collections_refused: [POLAR_LEG_WORKOUTS] } });
|
||||
await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_WORKOUTS });
|
||||
return 0;
|
||||
}
|
||||
await recordPolarWorkoutFailure(userId, err);
|
||||
throw markSyncFailureRecorded(err);
|
||||
}
|
||||
@@ -110,6 +124,7 @@ export async function syncUserPolarWorkouts(userId: string): Promise<number> {
|
||||
invalidateUserMeasurements(userId);
|
||||
}
|
||||
|
||||
await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_WORKOUTS });
|
||||
return imported;
|
||||
}
|
||||
|
||||
|
||||
+62
-14
@@ -13,12 +13,19 @@
|
||||
* exactly like Nightscout's token-rejected path.
|
||||
*
|
||||
* Isolation: the five vitals collections are settled independently
|
||||
* (`Promise.allSettled`), so a single dead collection — e.g. a 403 on SpO2 or
|
||||
* cardio-load on an account without the sensor or the higher tier — imports the
|
||||
* healthy four and records ONE partial failure rather than blanking every
|
||||
* collection. Mirrors Oura's shipped per-collection isolation, minus the
|
||||
* token-refresh leg Polar structurally lacks (a 401 is just a per-collection
|
||||
* failure the classifier maps to `reauth_required`).
|
||||
* (`Promise.allSettled`), so a single dead collection imports the healthy four
|
||||
* and records ONE partial failure rather than blanking every collection.
|
||||
* Mirrors Oura's shipped per-collection isolation, minus the token-refresh leg
|
||||
* Polar structurally lacks (a 401 is just a per-collection failure the
|
||||
* classifier maps to `reauth_required`).
|
||||
*
|
||||
* A 403 on one collection while a sibling succeeded is a refusal, not a
|
||||
* failure (`isPolarCollectionRefusal`): the same token just worked, so the
|
||||
* account simply does not share that data type (SpO2 without the sensor,
|
||||
* cardio load without the tier). Counting it as `reauth_required` put a
|
||||
* working connection behind a "reconnect" prompt that no reconnect clears.
|
||||
* The refused names ride the wide event; the leg records success. Mirrors
|
||||
* Oura's handling of a scope the grant does not cover.
|
||||
*
|
||||
* Window contract: every hourly tick re-reads the full server-fixed window for
|
||||
* each collection — 28 days for recharge / sleep / activity / cardio-load /
|
||||
@@ -39,7 +46,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
@@ -80,7 +87,11 @@ import {
|
||||
type SleepSegmentSweep,
|
||||
} from "@/lib/sleep/sweep-stale-segments";
|
||||
import { getPolarConnection } from "./credentials";
|
||||
import { PolarApiError, classifyPolarError } from "./response-classifier";
|
||||
import {
|
||||
PolarApiError,
|
||||
classifyPolarError,
|
||||
isPolarCollectionRefusal,
|
||||
} from "./response-classifier";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
/** Map a Polar error onto the shared integration-ledger failure kind. */
|
||||
@@ -164,6 +175,15 @@ export interface PolarCollectionFailure {
|
||||
err: unknown;
|
||||
}
|
||||
|
||||
/** The vitals leg's outcome, plus whether any collection proved the token. */
|
||||
export interface PolarVitalsResult extends SyncWriteResult {
|
||||
/**
|
||||
* True when at least one collection read succeeded with the token, so a 403
|
||||
* elsewhere in this run (the workouts leg included) is a per-type refusal.
|
||||
*/
|
||||
tokenProven: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync one user's Polar data. Returns the rows written AND whether a
|
||||
* collection did not settle — the bare count used to narrow the partial away,
|
||||
@@ -177,9 +197,11 @@ export interface PolarCollectionFailure {
|
||||
* escapes now is a write-path/unexpected error out of `upsertPolarMeasurements`
|
||||
* — the poll-cohort boundary records that unmarked escape once.
|
||||
*/
|
||||
export async function syncUserPolar(userId: string): Promise<SyncWriteResult> {
|
||||
export async function syncUserPolar(
|
||||
userId: string,
|
||||
): Promise<PolarVitalsResult> {
|
||||
const conn = await getPolarConnection(userId);
|
||||
if (!conn) return { imported: 0, failed: false };
|
||||
if (!conn) return { imported: 0, failed: false, tokenProven: false };
|
||||
// Date-keyed rows are anchored at local noon of their date in this zone.
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
|
||||
@@ -250,15 +272,23 @@ export async function syncUserPolar(userId: string): Promise<SyncWriteResult> {
|
||||
collections.map((spec) => spec.collect(conn.accessToken)),
|
||||
);
|
||||
const readings: PolarMeasurementUpsert[] = [];
|
||||
const failures: PolarCollectionFailure[] = [];
|
||||
const settledFailures: PolarCollectionFailure[] = [];
|
||||
settled.forEach((res, i) => {
|
||||
const spec = collections[i]!;
|
||||
if (res.status === "fulfilled") {
|
||||
readings.push(...res.value);
|
||||
} else {
|
||||
failures.push({ name: spec.name, err: res.reason });
|
||||
settledFailures.push({ name: spec.name, err: res.reason });
|
||||
}
|
||||
});
|
||||
const tokenProven = settled.some((res) => res.status === "fulfilled");
|
||||
const { failures, refused } = splitPolarRefusals(
|
||||
settledFailures,
|
||||
tokenProven,
|
||||
);
|
||||
if (refused.length > 0) {
|
||||
annotate({ meta: { polar_collections_refused: refused } });
|
||||
}
|
||||
|
||||
// Clear whatever an earlier scoring left under the re-fetched nights before
|
||||
// the fresh set upserts (mirrors Google Health's replace-by-window order).
|
||||
@@ -317,11 +347,29 @@ export async function syncUserPolar(userId: string): Promise<SyncWriteResult> {
|
||||
? String(firstErr.httpStatus)
|
||||
: undefined,
|
||||
});
|
||||
return { imported, failed: true };
|
||||
return { imported, failed: true, tokenProven };
|
||||
}
|
||||
|
||||
await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_VITALS });
|
||||
return { imported, failed: false };
|
||||
return { imported, failed: false, tokenProven };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a run's collection failures into the ones that count and the per-type
|
||||
* refusals (a 403 while another read proved the token). Without a proof every
|
||||
* failure counts: a 403 on every collection is the account-wide consent gap.
|
||||
*/
|
||||
export function splitPolarRefusals(
|
||||
failures: PolarCollectionFailure[],
|
||||
tokenProven: boolean,
|
||||
): { failures: PolarCollectionFailure[]; refused: string[] } {
|
||||
if (!tokenProven) return { failures, refused: [] };
|
||||
return {
|
||||
failures: failures.filter((f) => !isPolarCollectionRefusal(f.err)),
|
||||
refused: failures
|
||||
.filter((f) => isPolarCollectionRefusal(f.err))
|
||||
.map((f) => f.name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Strava's rate limit and its detail budget.
|
||||
*
|
||||
* - A 429 mid-walk used to discard everything the run had read: the error
|
||||
* threw out of the walk before the upsert. The activities read before it
|
||||
* land now, the cursor moves only as far as an oldest-first walk proves
|
||||
* safe, the failure is booked as transient, and the backfill waits for
|
||||
* the window Strava's headers name before it goes on.
|
||||
* - An update mapped from the summary alone (detail budget spent, or a
|
||||
* detail read that failed) wrote `totalEnergyKcal: null` over the
|
||||
* calories an earlier detail read had stored. The fields only the detail
|
||||
* carries are kept when this run did not read it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchActivities: vi.fn(),
|
||||
fetchActivityById: vi.fn(),
|
||||
annotate: vi.fn(),
|
||||
findUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
updateManyUser: vi.fn(async (_arg: unknown) => ({ count: 1 })),
|
||||
createManyAndReturn: vi.fn(),
|
||||
updateWorkout: vi.fn(async (_arg: { data: Record<string, unknown> }) => ({})),
|
||||
findWorkout: vi.fn(),
|
||||
recordSyncFailure: vi.fn(async () => {}),
|
||||
recordSyncSuccess: vi.fn(async () => {}),
|
||||
isReauthRequired: vi.fn(async () => false),
|
||||
send: vi.fn(async (..._args: unknown[]) => "job-next"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mocks.findUser,
|
||||
updateMany: mocks.updateManyUser,
|
||||
update: mocks.updateUser,
|
||||
},
|
||||
workout: {
|
||||
createManyAndReturn: mocks.createManyAndReturn,
|
||||
update: mocks.updateWorkout,
|
||||
findUnique: mocks.findWorkout,
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/workout-emit", () => ({
|
||||
emitInsertedWorkoutArrival: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: mocks.annotate,
|
||||
getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/status", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/integrations/status")>()),
|
||||
recordSyncFailure: mocks.recordSyncFailure,
|
||||
recordSyncSuccess: mocks.recordSyncSuccess,
|
||||
isReauthRequired: mocks.isReauthRequired,
|
||||
}));
|
||||
vi.mock("../credentials", () => ({
|
||||
getStravaConnection: vi.fn(async () => ({
|
||||
accessToken: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
refreshTokenCiphertext: Buffer.from("ciphertext"),
|
||||
})),
|
||||
getStravaClientCredentials: vi.fn(),
|
||||
storeStravaTokens: vi.fn(),
|
||||
}));
|
||||
vi.mock("../client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../client")>()),
|
||||
fetchActivities: mocks.fetchActivities,
|
||||
fetchActivityById: mocks.fetchActivityById,
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: vi.fn(() => ({ send: mocks.send })),
|
||||
}));
|
||||
|
||||
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
|
||||
import { StravaApiError } from "../response-classifier";
|
||||
import { stravaRateLimitRetryAfterSeconds } from "../client";
|
||||
import { syncUserStrava } from "../sync";
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const T0 = Date.UTC(2026, 9, 1);
|
||||
|
||||
function activity(id: number) {
|
||||
return {
|
||||
id,
|
||||
sport_type: "Run",
|
||||
start_date: new Date(T0 + id * HOUR).toISOString(),
|
||||
moving_time: 1800,
|
||||
elapsed_time: 1900,
|
||||
average_heartrate: 140,
|
||||
};
|
||||
}
|
||||
|
||||
function rateLimited(retryAfterSeconds: number): StravaApiError {
|
||||
return new StravaApiError({
|
||||
verb: "fetchActivities",
|
||||
classification: "transient",
|
||||
httpStatus: 429,
|
||||
reason: "http_429",
|
||||
retryAfterSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findUser.mockResolvedValue({
|
||||
stravaLastActivityAt: new Date(T0),
|
||||
stravaBackfillResumeBefore: null,
|
||||
});
|
||||
mocks.createManyAndReturn.mockImplementation(
|
||||
async ({ data }: { data: { externalId: string; startedAt: Date } }) => [
|
||||
{ id: `w-${data.externalId}`, startedAt: data.startedAt },
|
||||
],
|
||||
);
|
||||
mocks.fetchActivityById.mockImplementation(
|
||||
async (_t: string, id: number) => ({
|
||||
...activity(id),
|
||||
calories: 300,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("a 429 in the middle of a walk", () => {
|
||||
it("keeps what was read, moves the cursor an oldest-first walk proves, and books it transient", async () => {
|
||||
// An incremental walk (`after` set): Strava answers oldest first. Three
|
||||
// activities read, then the detail read of the fourth is refused.
|
||||
mocks.fetchActivities.mockResolvedValue([1, 2, 3, 4, 5].map(activity));
|
||||
mocks.fetchActivityById.mockImplementation(
|
||||
async (_t: string, id: number) => {
|
||||
if (id === 4) throw rateLimited(600);
|
||||
return { ...activity(id), calories: 300 };
|
||||
},
|
||||
);
|
||||
|
||||
await expect(syncUserStrava("user-1")).resolves.toBe(3);
|
||||
|
||||
expect(mocks.createManyAndReturn).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.updateManyUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { stravaLastActivityAt: new Date(T0 + 3 * HOUR) },
|
||||
}),
|
||||
);
|
||||
expect(mocks.recordSyncFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "transient", errorCode: "429" }),
|
||||
);
|
||||
expect(mocks.recordSyncSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not move the cursor past a newest-first walk it did not finish", async () => {
|
||||
mocks.fetchActivities.mockResolvedValue([9, 8, 7].map(activity));
|
||||
mocks.fetchActivityById.mockImplementation(
|
||||
async (_t: string, id: number) => {
|
||||
if (id === 7) throw rateLimited(600);
|
||||
return { ...activity(id), calories: 300 };
|
||||
},
|
||||
);
|
||||
|
||||
await expect(syncUserStrava("user-1")).resolves.toBe(2);
|
||||
expect(mocks.updateManyUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the next backfill run after the window Strava named", async () => {
|
||||
// A history walk (no `after`): newest first, refused on the second page.
|
||||
mocks.fetchActivities.mockImplementation(
|
||||
async (_t: string, q: { page: number }) => {
|
||||
if (q.page === 2) throw rateLimited(5400);
|
||||
return Array.from({ length: 100 }, (_, i) => activity(500 - i));
|
||||
},
|
||||
);
|
||||
|
||||
const run = await runStravaBackfillForUser("user-1");
|
||||
|
||||
expect(run).toEqual({ imported: 100, continued: true });
|
||||
expect(mocks.updateUser).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
stravaBackfillResumeBefore: new Date(T0 + 401 * HOUR + 1000),
|
||||
},
|
||||
});
|
||||
const [, , options] = mocks.send.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
{ startAfter?: number },
|
||||
];
|
||||
expect(options.startAfter).toBe(5400);
|
||||
expect(mocks.updateUser).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
stravaBackfillCompletedAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an update without the detail read", () => {
|
||||
it("keeps the stored calories, heart rate and description", async () => {
|
||||
// The row exists already; this run reads only the summary (no HR on it)
|
||||
// because the detail read failed.
|
||||
mocks.createManyAndReturn.mockResolvedValue([]);
|
||||
mocks.fetchActivities.mockResolvedValue([
|
||||
{ ...activity(1), average_heartrate: undefined },
|
||||
]);
|
||||
mocks.fetchActivityById.mockRejectedValue(new Error("detail down"));
|
||||
mocks.findWorkout.mockResolvedValue({
|
||||
metadata: { stravaName: "old", stravaDescription: "Hill reps" },
|
||||
});
|
||||
|
||||
await syncUserStrava("user-1");
|
||||
|
||||
const data = mocks.updateWorkout.mock.calls[0]![0].data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(data).not.toHaveProperty("totalEnergyKcal");
|
||||
expect(data).not.toHaveProperty("avgHeartRate");
|
||||
expect(data).not.toHaveProperty("maxHeartRate");
|
||||
expect(data.metadata).toMatchObject({ stravaDescription: "Hill reps" });
|
||||
expect(data.durationSec).toBe(1800);
|
||||
});
|
||||
|
||||
it("writes what a detail read returned, nulls included", async () => {
|
||||
mocks.createManyAndReturn.mockResolvedValue([]);
|
||||
mocks.fetchActivities.mockResolvedValue([activity(1)]);
|
||||
mocks.fetchActivityById.mockResolvedValue({
|
||||
...activity(1),
|
||||
calories: null,
|
||||
});
|
||||
|
||||
await syncUserStrava("user-1");
|
||||
|
||||
const data = mocks.updateWorkout.mock.calls[0]![0].data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(data.totalEnergyKcal).toBeNull();
|
||||
expect(mocks.findWorkout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stravaRateLimitRetryAfterSeconds", () => {
|
||||
const at = new Date("2026-10-21T10:07:30Z");
|
||||
|
||||
it("waits for the next quarter hour when the short window is spent", () => {
|
||||
const headers = new Headers({
|
||||
"X-RateLimit-Limit": "200,2000",
|
||||
"X-RateLimit-Usage": "200,950",
|
||||
});
|
||||
expect(stravaRateLimitRetryAfterSeconds(headers, at)).toBe(450);
|
||||
});
|
||||
|
||||
it("waits for midnight UTC when the daily limit is spent", () => {
|
||||
const headers = new Headers({
|
||||
"X-ReadRateLimit-Limit": "100,1000",
|
||||
"X-ReadRateLimit-Usage": "40,1000",
|
||||
});
|
||||
expect(stravaRateLimitRetryAfterSeconds(headers, at)).toBe(
|
||||
13 * 3600 + 52 * 60 + 30,
|
||||
);
|
||||
});
|
||||
|
||||
it("honours a Retry-After header when one is sent", () => {
|
||||
expect(
|
||||
stravaRateLimitRetryAfterSeconds(
|
||||
new Headers({ "Retry-After": "120" }),
|
||||
at,
|
||||
),
|
||||
).toBe(120);
|
||||
});
|
||||
|
||||
it("falls back to the next quarter hour without any header", () => {
|
||||
expect(stravaRateLimitRetryAfterSeconds(new Headers(), at)).toBe(450);
|
||||
});
|
||||
});
|
||||
@@ -258,11 +258,54 @@ async function stravaGet<T>(
|
||||
classification: verdict.classification,
|
||||
httpStatus: verdict.httpStatus,
|
||||
reason: verdict.reason,
|
||||
...(res.status === 429
|
||||
? { retryAfterSeconds: stravaRateLimitRetryAfterSeconds(res.headers) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return (await res.json().catch(() => null)) as T;
|
||||
}
|
||||
|
||||
const QUARTER_HOUR_MS = 15 * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How long to wait after a 429, from the response's own headers. Strava sends
|
||||
* no `Retry-After` (one is honoured should it appear); it sends
|
||||
* `X-RateLimit-Limit` / `X-RateLimit-Usage` and, for reads,
|
||||
* `X-ReadRateLimit-Limit` / `X-ReadRateLimit-Usage`, each `<15-min>,<daily>`.
|
||||
* The 15-minute window resets at :00, :15, :30 and :45, the daily one at
|
||||
* midnight UTC (developers.strava.com/docs/rate-limits). A spent daily count
|
||||
* waits for midnight; anything else, the next quarter hour.
|
||||
*/
|
||||
export function stravaRateLimitRetryAfterSeconds(
|
||||
headers: Headers,
|
||||
now: Date = new Date(),
|
||||
): number {
|
||||
const retryAfter = Number(headers.get("retry-after"));
|
||||
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
||||
return Math.ceil(retryAfter);
|
||||
}
|
||||
const pair = (name: string): [number, number] | null => {
|
||||
const parts = (headers.get(name) ?? "").split(",").map(Number);
|
||||
return parts.length === 2 && parts.every(Number.isFinite)
|
||||
? [parts[0]!, parts[1]!]
|
||||
: null;
|
||||
};
|
||||
const dailySpent = (limitName: string, usageName: string): boolean => {
|
||||
const limit = pair(limitName);
|
||||
const usage = pair(usageName);
|
||||
return limit !== null && usage !== null && usage[1] >= limit[1];
|
||||
};
|
||||
const nowMs = now.getTime();
|
||||
const resetAt =
|
||||
dailySpent("x-ratelimit-limit", "x-ratelimit-usage") ||
|
||||
dailySpent("x-readratelimit-limit", "x-readratelimit-usage")
|
||||
? Math.floor(nowMs / DAY_MS) * DAY_MS + DAY_MS
|
||||
: Math.floor(nowMs / QUARTER_HOUR_MS) * QUARTER_HOUR_MS + QUARTER_HOUR_MS;
|
||||
return Math.max(1, Math.ceil((resetAt - nowMs) / 1000));
|
||||
}
|
||||
|
||||
/** One page of the athlete's activities (newest first). */
|
||||
export async function fetchActivities(
|
||||
accessToken: string,
|
||||
@@ -312,6 +355,13 @@ export interface StravaWorkoutRow {
|
||||
maxHeartRate: number | null;
|
||||
elevationM: number | null;
|
||||
metadata: Prisma.InputJsonValue;
|
||||
/**
|
||||
* True when the row was mapped from the summary alone. The calories, the
|
||||
* description and (when the summary lacks it) the heart rate exist only on
|
||||
* the detail read, so an update from such a row must not write their
|
||||
* absence over what an earlier detail read stored.
|
||||
*/
|
||||
summaryOnly?: boolean;
|
||||
}
|
||||
|
||||
function nonNegInt(n: number | null | undefined): number | null {
|
||||
@@ -414,6 +464,7 @@ export function mapActivity(
|
||||
summary.total_elevation_gain ?? detail?.total_elevation_gain,
|
||||
),
|
||||
metadata,
|
||||
summaryOnly: detail == null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,18 +28,32 @@ export function classifyStravaResponse(
|
||||
}
|
||||
|
||||
export class StravaApiError extends IntegrationApiError {
|
||||
/**
|
||||
* On a 429: how long Strava's own headers say to wait before asking again
|
||||
* (`stravaRateLimitRetryAfterSeconds`). Undefined on every other failure.
|
||||
*/
|
||||
readonly retryAfterSeconds: number | undefined;
|
||||
|
||||
constructor(opts: {
|
||||
verb: string;
|
||||
classification: IntegrationClassification;
|
||||
httpStatus: number | undefined;
|
||||
reason: string;
|
||||
upstreamError?: string;
|
||||
retryAfterSeconds?: number;
|
||||
}) {
|
||||
super({ vendor: "Strava", ...opts });
|
||||
const { retryAfterSeconds, ...rest } = opts;
|
||||
super({ vendor: "Strava", ...rest });
|
||||
this.name = "StravaApiError";
|
||||
this.retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
/** True for Strava's rate-limit refusal. */
|
||||
export function isStravaRateLimited(err: unknown): err is StravaApiError {
|
||||
return err instanceof StravaApiError && err.httpStatus === 429;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
|
||||
* the canonical revoked / rotated-away refresh-token signal. Lifts a 400
|
||||
|
||||
+116
-24
@@ -21,10 +21,14 @@
|
||||
* Rate limits: 200 req / 15 min + 2000 / day per app. The walk is page-bounded
|
||||
* (`walkPages`, which reports a walk cut off at its ceiling instead of
|
||||
* passing it off as the whole history) and the per-activity detail fetch (for `calories`, absent from the summary)
|
||||
* is budget-capped; a 429 mid-walk records a transient failure and the next
|
||||
* cron tick resumes from the cursor.
|
||||
* is budget-capped. A 429 mid-walk ends the walk where it stands: what was
|
||||
* read lands, the cursor moves only as far as an oldest-first walk proves, the
|
||||
* failure is booked transient, and the run reports how long Strava's headers
|
||||
* say to wait (`retryAfterSeconds`), which the backfill waits out before its
|
||||
* next run. It used to throw out of the walk and discard everything read.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
@@ -50,7 +54,11 @@ import {
|
||||
getStravaConnection,
|
||||
storeStravaTokens,
|
||||
} from "./credentials";
|
||||
import { StravaApiError, classifyStravaError } from "./response-classifier";
|
||||
import {
|
||||
StravaApiError,
|
||||
classifyStravaError,
|
||||
isStravaRateLimited,
|
||||
} from "./response-classifier";
|
||||
|
||||
/** Default first-incremental lookback (days) when no cursor exists yet. */
|
||||
export const STRAVA_SYNC_LOOKBACK_DAYS = 30;
|
||||
@@ -115,6 +123,8 @@ export interface StravaHistoryRun {
|
||||
* expect.
|
||||
*/
|
||||
oldestStart: Date | null;
|
||||
/** Set when Strava's rate limit ended the run: seconds until it resets. */
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,6 +213,9 @@ async function runStravaSync(
|
||||
let oldestStart: Date | null = null;
|
||||
let previousStart: number | null = null;
|
||||
let newestFirst = true;
|
||||
// Assigned inside the page callback; the cast keeps the compiler from
|
||||
// narrowing it to its initial `null` after the walk.
|
||||
let rateLimited = null as StravaApiError | null;
|
||||
|
||||
try {
|
||||
// Strava pages by number; a short page is the last one. A walk that
|
||||
@@ -213,29 +226,37 @@ async function runStravaSync(
|
||||
maxPages,
|
||||
async (cursor) => {
|
||||
const page = cursor ?? 1;
|
||||
const activities = await authed((t) =>
|
||||
fetchActivities(t, {
|
||||
after: afterEpoch,
|
||||
before: beforeEpoch,
|
||||
page,
|
||||
perPage: PER_PAGE,
|
||||
}),
|
||||
);
|
||||
let activities: Awaited<ReturnType<typeof fetchActivities>>;
|
||||
try {
|
||||
activities = await authed((t) =>
|
||||
fetchActivities(t, {
|
||||
after: afterEpoch,
|
||||
before: beforeEpoch,
|
||||
page,
|
||||
perPage: PER_PAGE,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isStravaRateLimited(err)) throw err;
|
||||
rateLimited = err;
|
||||
return { items: [], next: null, interrupted: true };
|
||||
}
|
||||
const mapped: StravaWorkoutRow[] = [];
|
||||
for (const a of activities) {
|
||||
// Fetch detail only to fill `calories` (absent from the summary),
|
||||
// and only while the budget allows. When the summary already lacks
|
||||
// HR we also let the detail top it up. A detail failure is
|
||||
// non-fatal — map from the summary — but a 429 aborts the whole
|
||||
// walk (transient).
|
||||
// non-fatal — map from the summary — but a 429 ends the walk here,
|
||||
// keeping the activities before this one.
|
||||
let detail: StravaDetailedActivity | null = null;
|
||||
if (detailBudget > 0) {
|
||||
try {
|
||||
detail = await authed((t) => fetchActivityById(t, a.id));
|
||||
detailBudget -= 1;
|
||||
} catch (err) {
|
||||
if (err instanceof StravaApiError && err.httpStatus === 429) {
|
||||
throw err;
|
||||
if (isStravaRateLimited(err)) {
|
||||
rateLimited = err;
|
||||
return { items: mapped, next: null, interrupted: true };
|
||||
}
|
||||
getEvent()?.addWarning(
|
||||
`strava: activity detail fetch failed for ${a.id}: ${err}`,
|
||||
@@ -277,6 +298,12 @@ async function runStravaSync(
|
||||
throw markSyncFailureRecorded(err);
|
||||
}
|
||||
|
||||
// A walk the rate limit ended is a prefix of the pages in the order Strava
|
||||
// served them. Oldest first (an incremental walk), every activity up to the
|
||||
// newest one read is in; newest first, older ones are still missing, and
|
||||
// moving the cursor would skip them for good.
|
||||
const cursorSafe = rateLimited === null || !newestFirst;
|
||||
|
||||
let imported: number;
|
||||
try {
|
||||
imported = await upsertStravaWorkouts(userId, rows);
|
||||
@@ -284,7 +311,7 @@ async function runStravaSync(
|
||||
// Advance the incremental cursor to the newest activity start we saw, but
|
||||
// never move it backwards (a bounded backfill page-walk may not reach the
|
||||
// very newest activity in one pass).
|
||||
if (newestStart) {
|
||||
if (newestStart && cursorSafe) {
|
||||
await prisma.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -301,6 +328,26 @@ async function runStravaSync(
|
||||
throw markSyncFailureRecorded(err);
|
||||
}
|
||||
|
||||
if (rateLimited) {
|
||||
// Not finished, so no success; booked transient, the next run goes on.
|
||||
await recordStravaFailure(userId, rateLimited);
|
||||
if (imported > 0) invalidateUserMeasurements(userId);
|
||||
annotate({
|
||||
action: {
|
||||
name: "strava.sync.rate_limited",
|
||||
details: {
|
||||
imported,
|
||||
retry_after_seconds: rateLimited.retryAfterSeconds ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
imported,
|
||||
oldestStart: newestFirst ? oldestStart : null,
|
||||
retryAfterSeconds: rateLimited.retryAfterSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
await recordSyncSuccess(userId, "strava");
|
||||
|
||||
// Background-sync posture: sweep the workouts cache and mark the analytics
|
||||
@@ -316,6 +363,42 @@ async function runStravaSync(
|
||||
return { imported, oldestStart: newestFirst ? oldestStart : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The update for a row mapped from the summary alone. Calories, the
|
||||
* description and (when the summary has none) the heart rate come only from
|
||||
* the detail read, so their absence here says nothing: the stored values stay.
|
||||
* A detail read that did run is written as it came, nulls included.
|
||||
*/
|
||||
async function summaryOnlyUpdate<
|
||||
D extends {
|
||||
totalEnergyKcal: number | null;
|
||||
avgHeartRate: number | null;
|
||||
maxHeartRate: number | null;
|
||||
metadata: Prisma.InputJsonValue;
|
||||
},
|
||||
>(data: D, stored: { metadata: Prisma.JsonValue } | null) {
|
||||
const { totalEnergyKcal, avgHeartRate, maxHeartRate, metadata, ...rest } =
|
||||
data;
|
||||
const storedMeta =
|
||||
stored?.metadata && typeof stored.metadata === "object"
|
||||
? (stored.metadata as Record<string, unknown>)
|
||||
: {};
|
||||
const description = storedMeta.stravaDescription;
|
||||
return {
|
||||
...rest,
|
||||
...(totalEnergyKcal != null ? { totalEnergyKcal } : {}),
|
||||
...(avgHeartRate != null ? { avgHeartRate } : {}),
|
||||
...(maxHeartRate != null ? { maxHeartRate } : {}),
|
||||
metadata:
|
||||
typeof description === "string"
|
||||
? ({
|
||||
...(metadata as Record<string, unknown>),
|
||||
stravaDescription: description,
|
||||
} as Prisma.InputJsonValue)
|
||||
: metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a batch of mapped Strava activities into `Workout`, keyed on
|
||||
* `(userId, source = STRAVA, externalId)` so a re-fetch overwrites in place.
|
||||
@@ -362,15 +445,24 @@ export async function upsertStravaWorkouts(
|
||||
() => {},
|
||||
);
|
||||
} else {
|
||||
await prisma.workout.update({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "STRAVA",
|
||||
externalId: r.externalId,
|
||||
},
|
||||
const where = {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "STRAVA" as const,
|
||||
externalId: r.externalId,
|
||||
},
|
||||
data,
|
||||
};
|
||||
await prisma.workout.update({
|
||||
where,
|
||||
data: r.summaryOnly
|
||||
? await summaryOnlyUpdate(
|
||||
data,
|
||||
await prisma.workout.findUnique({
|
||||
where,
|
||||
select: { metadata: true },
|
||||
}),
|
||||
)
|
||||
: data,
|
||||
});
|
||||
}
|
||||
imported += 1;
|
||||
|
||||
@@ -533,6 +533,84 @@ describe("parseSourcePriority — duplicate tolerance (v1.16.11)", () => {
|
||||
const ladder = resolved.metricPriority.weight;
|
||||
expect(new Set(ladder).size).toBe(ladder.length);
|
||||
expect(ladder.slice(0, 2)).toEqual(["MANUAL", "WITHINGS"]);
|
||||
expect(ladder.length).toBeLessThanOrEqual(8);
|
||||
// The schema caps a ladder at one entry per source.
|
||||
expect(ladder.length).toBeLessThanOrEqual(
|
||||
measurementSourceEnum.options.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_SOURCE_PRIORITY — the sources that had no place (v1.43.11)", () => {
|
||||
const HAND_ENTERED = ["EXTERNAL", "MCP", "TELEGRAM"] as const;
|
||||
// What Health Connect imports, by ladder (`import/health-connect/mapper.ts`).
|
||||
const HEALTH_CONNECT_LADDERS = [
|
||||
"steps",
|
||||
"activeEnergy",
|
||||
"walkingRunningDistance",
|
||||
"bloodPressure",
|
||||
"bodyFat",
|
||||
"bodyTemperature",
|
||||
"hrvRmssd",
|
||||
"spo2",
|
||||
"respiratoryRate",
|
||||
"restingHeartRate",
|
||||
"vo2Max",
|
||||
"weight",
|
||||
] as const;
|
||||
|
||||
it("ranks the hand-entered sources right after MANUAL wherever MANUAL ranks", () => {
|
||||
for (const [key, ladder] of Object.entries(DEFAULT_SOURCE_PRIORITY)) {
|
||||
const i = (ladder as string[]).indexOf("MANUAL");
|
||||
if (i === -1) continue;
|
||||
expect(ladder.slice(i + 1, i + 1 + HAND_ENTERED.length), key).toEqual([
|
||||
...HAND_ENTERED,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("ranks Health Connect right after Apple Health on every ladder it imports for", () => {
|
||||
for (const key of HEALTH_CONNECT_LADDERS) {
|
||||
const ladder = DEFAULT_SOURCE_PRIORITY[key] as string[];
|
||||
expect(ladder[ladder.indexOf("APPLE_HEALTH") + 1], key).toBe(
|
||||
"HEALTH_CONNECT",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the order of the sources the ladders already named alone", () => {
|
||||
const added = new Set<string>([...HAND_ENTERED, "HEALTH_CONNECT"]);
|
||||
const without = (key: keyof typeof DEFAULT_SOURCE_PRIORITY) =>
|
||||
DEFAULT_SOURCE_PRIORITY[key].filter((s) => !added.has(s));
|
||||
expect(without("weight")).toEqual([
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"MANUAL",
|
||||
"WHOOP",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
]);
|
||||
expect(without("bloodPressure")).toEqual([
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"MANUAL",
|
||||
]);
|
||||
expect(without("spo2")).toEqual([
|
||||
"WITHINGS",
|
||||
"WHOOP",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"APPLE_HEALTH",
|
||||
"MANUAL",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fits every default ladder within the schema cap", () => {
|
||||
for (const [key, ladder] of Object.entries(DEFAULT_SOURCE_PRIORITY)) {
|
||||
expect(ladder.length, key).toBeLessThanOrEqual(
|
||||
measurementSourceEnum.options.length,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -234,6 +234,16 @@ export type DeviceTypePriority = z.infer<typeof deviceTypePrioritySchema>;
|
||||
* pulse-ox, Thermo). Apple Health is second-hand (HealthKit
|
||||
* receives the same reading via Withings' Health Mate iOS app).
|
||||
*/
|
||||
// v1.43.11 — the sources that had no place on any ladder. A source a ladder
|
||||
// does not name ranks 90, below everything it does, so a typed weight from a
|
||||
// Telegram reminder lost its day to a WHOOP strap's estimate, and a Health
|
||||
// Connect day lost to every other source. Health Connect is the Android
|
||||
// counterpart of the HealthKit relay and sits right after APPLE_HEALTH on
|
||||
// the ladders for what it imports; the readings a person enters or bridges
|
||||
// themselves (EXTERNAL: their own hardware's uploader, MCP: an assistant
|
||||
// writing on their word, TELEGRAM: a reply to a reminder) sit right after
|
||||
// MANUAL, in the alphabetical order they already broke ties in. Relative to
|
||||
// each other the existing sources keep their order.
|
||||
export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
// v1.12.0 — Fitbit/Pixel rides below Apple Health for cumulative metrics:
|
||||
// HealthKit still aggregates the broadest device set, and a Fitbit-only
|
||||
@@ -253,6 +263,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
// still survives.
|
||||
steps: [
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"WITHINGS",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
@@ -260,24 +271,35 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"POLAR",
|
||||
"STRAVA",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
],
|
||||
activeEnergy: [
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"WITHINGS",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
],
|
||||
walkingRunningDistance: [
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"WITHINGS",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
],
|
||||
flightsClimbed: [
|
||||
"APPLE_HEALTH",
|
||||
@@ -285,6 +307,9 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
],
|
||||
// v1.11.0 — WHOOP leads the recovery-input ladders (sleep / HRV / RHR):
|
||||
// a worn-all-night strap has higher-resolution overnight sampling than
|
||||
@@ -319,18 +344,31 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"WITHINGS",
|
||||
],
|
||||
// A real scale beats a strap's body-measurement estimate for weight.
|
||||
weight: [
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
"WHOOP",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
],
|
||||
bloodPressure: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
|
||||
bloodPressure: [
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
],
|
||||
// v1.17.0 — continuous optical-HR wearables (Fitbit / Polar / Oura) rank
|
||||
// above a hand-typed MANUAL pulse; the device-source order matches the
|
||||
// rhr / hrv ladders (FITBIT > OURA > POLAR). Withings BPM cuff + the
|
||||
@@ -343,12 +381,29 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
],
|
||||
bodyFat: [
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
],
|
||||
bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT", "GOOGLE_HEALTH"],
|
||||
bodyTemperature: [
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
],
|
||||
@@ -363,15 +418,23 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
],
|
||||
vo2Max: [
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"OURA",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
"TELEGRAM",
|
||||
],
|
||||
// v1.11.0 — new WHOOP-overlapping keys. ScanWatch dermal reading is the
|
||||
// primary skin-temperature sensor; WHOOP's strap is second, Fitbit third,
|
||||
@@ -386,6 +449,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"WITHINGS",
|
||||
],
|
||||
// v1.11.0 — native-vs-derived recovery. WHOOP's device-native Recovery
|
||||
@@ -404,8 +468,18 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
// on now. Mirrors the recovery ladder's native-above-computed ordering.
|
||||
sleepDebt: ["WHOOP", "OURA", "POLAR", "COMPUTED"],
|
||||
// v1.42 (#1110) — the straps and rings that measure RMSSD overnight lead;
|
||||
// the HealthKit relay of a third-party reading is the fallback.
|
||||
hrvRmssd: ["WHOOP", "OURA", "POLAR", "APPLE_HEALTH"],
|
||||
// the HealthKit relay of a third-party reading is the fallback. Fitbit and
|
||||
// Google Health report RMSSD too and take the places they hold on the SDNN
|
||||
// ladder they used to be filed under.
|
||||
hrvRmssd: [
|
||||
"WHOOP",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* The v2 recovery webhook carries the id of the associated SLEEP, and WHOOP
|
||||
* reads a recovery only through its cycle. The targeted refresh must resolve
|
||||
* sleep -> cycle -> recovery; passing the sleep UUID straight into the cycle
|
||||
* route answered 404 on every recovery webhook and booked a sync failure.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchSleepById, fetchRecoveryByCycleId, upsertMock, fetchErrorMock } =
|
||||
vi.hoisted(() => ({
|
||||
fetchSleepById: vi.fn(),
|
||||
fetchRecoveryByCycleId: vi.fn(),
|
||||
upsertMock: vi.fn(async (_u: string, rows: unknown[]) => rows.length),
|
||||
fetchErrorMock: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("../sync-core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../sync-core")>()),
|
||||
getValidToken: vi.fn(async () => ({ accessToken: "tok" })),
|
||||
upsertWhoopMeasurements: upsertMock,
|
||||
handleCollectionFetchError: fetchErrorMock,
|
||||
}));
|
||||
|
||||
vi.mock("../client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../client")>()),
|
||||
fetchSleepById,
|
||||
fetchRecoveryByCycleId,
|
||||
}));
|
||||
|
||||
import { syncWhoopRecoveryById } from "../sync-recovery";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("syncWhoopRecoveryById", () => {
|
||||
it("reads the sleep for its cycle id, then the recovery through the cycle", async () => {
|
||||
fetchSleepById.mockResolvedValue({ id: "sleep-uuid", cycle_id: 991 });
|
||||
fetchRecoveryByCycleId.mockResolvedValue({
|
||||
cycle_id: 991,
|
||||
sleep_id: "sleep-uuid",
|
||||
user_id: 42,
|
||||
created_at: "2026-06-01T07:00:00.000Z",
|
||||
updated_at: "2026-06-01T07:05:00.000Z",
|
||||
score_state: "SCORED",
|
||||
score: {
|
||||
user_calibrating: false,
|
||||
recovery_score: 70,
|
||||
resting_heart_rate: 52,
|
||||
hrv_rmssd_milli: 61,
|
||||
},
|
||||
});
|
||||
|
||||
const imported = await syncWhoopRecoveryById("user-1", "sleep-uuid");
|
||||
|
||||
expect(fetchSleepById).toHaveBeenCalledWith("tok", "sleep-uuid");
|
||||
expect(fetchRecoveryByCycleId).toHaveBeenCalledWith("tok", "991");
|
||||
expect(fetchErrorMock).not.toHaveBeenCalled();
|
||||
expect(imported).toBe(3);
|
||||
const rows = upsertMock.mock.calls[0][1] as Array<{ externalId: string }>;
|
||||
expect(rows.map((r) => r.externalId)).toEqual([
|
||||
"sleep-uuid:recovery",
|
||||
"sleep-uuid:hrv_rmssd",
|
||||
"sleep-uuid:rhr",
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports nothing when the sleep carries no cycle id", async () => {
|
||||
fetchSleepById.mockResolvedValue({ id: "sleep-uuid" });
|
||||
|
||||
expect(await syncWhoopRecoveryById("user-1", "sleep-uuid")).toBe(0);
|
||||
expect(fetchRecoveryByCycleId).not.toHaveBeenCalled();
|
||||
expect(upsertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a failed sleep read through the shared fetch-error path", async () => {
|
||||
const err = new Error("boom");
|
||||
fetchSleepById.mockRejectedValue(err);
|
||||
|
||||
await syncWhoopRecoveryById("user-1", "sleep-uuid");
|
||||
expect(fetchErrorMock).toHaveBeenCalledWith("recovery", "user-1", err);
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,18 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ setAuth: vi.fn(), addWarning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { processWhoopNotification } from "../webhook-handler";
|
||||
import {
|
||||
deletedResourceExternalIdFilter,
|
||||
processWhoopNotification,
|
||||
} from "../webhook-handler";
|
||||
import {
|
||||
mapRecovery,
|
||||
mapSleep,
|
||||
WHOOP_RECOVERY_FIELD_TAGS,
|
||||
type WhoopRecovery,
|
||||
type WhoopSleep,
|
||||
} from "../client";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -79,7 +90,7 @@ describe("processWhoopNotification — fetch-by-id enqueue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("carries a numeric resource id (cycle-based recovery) as a string", async () => {
|
||||
it("carries a numeric resource id as a string", async () => {
|
||||
await processWhoopNotification({
|
||||
user_id: 42,
|
||||
id: 9981,
|
||||
@@ -148,3 +159,107 @@ describe("processWhoopNotification — fetch-by-id enqueue", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// One night as WHOOP v2 models it: a sleep and the recovery scored from it.
|
||||
// Both key their rows off the sleep UUID, which is also the id the recovery
|
||||
// webhook carries.
|
||||
const SLEEP: WhoopSleep = {
|
||||
id: "sleep-uuid",
|
||||
cycle_id: 991,
|
||||
user_id: 42,
|
||||
created_at: "2026-06-01T05:00:00.000Z",
|
||||
updated_at: "2026-06-01T07:00:00.000Z",
|
||||
start: "2026-05-31T23:00:00.000Z",
|
||||
end: "2026-06-01T07:00:00.000Z",
|
||||
nap: false,
|
||||
score_state: "SCORED",
|
||||
score: {
|
||||
stage_summary: {
|
||||
total_in_bed_time_milli: 8 * 3_600_000,
|
||||
total_awake_time_milli: 30 * 60_000,
|
||||
total_light_sleep_time_milli: 4 * 3_600_000,
|
||||
total_slow_wave_sleep_time_milli: 90 * 60_000,
|
||||
total_rem_sleep_time_milli: 2 * 3_600_000,
|
||||
disturbance_count: 4,
|
||||
},
|
||||
sleep_needed: {
|
||||
baseline_milli: 8 * 3_600_000,
|
||||
need_from_sleep_debt_milli: 0,
|
||||
need_from_recent_strain_milli: 0,
|
||||
need_from_recent_nap_milli: 0,
|
||||
},
|
||||
respiratory_rate: 15,
|
||||
sleep_performance_percentage: 90,
|
||||
sleep_consistency_percentage: 80,
|
||||
sleep_efficiency_percentage: 92,
|
||||
},
|
||||
};
|
||||
const RECOVERY: WhoopRecovery = {
|
||||
cycle_id: 991,
|
||||
sleep_id: "sleep-uuid",
|
||||
user_id: 42,
|
||||
created_at: "2026-06-01T07:00:00.000Z",
|
||||
updated_at: "2026-06-01T07:05:00.000Z",
|
||||
score_state: "SCORED",
|
||||
score: {
|
||||
user_calibrating: false,
|
||||
recovery_score: 70,
|
||||
resting_heart_rate: 52,
|
||||
hrv_rmssd_milli: 61,
|
||||
spo2_percentage: 96,
|
||||
skin_temp_celsius: 33.4,
|
||||
},
|
||||
};
|
||||
const SLEEP_IDS = mapSleep(SLEEP).map(
|
||||
(m) => m.externalId ?? `${SLEEP.id}:${m.fieldTag}`,
|
||||
);
|
||||
const RECOVERY_IDS = mapRecovery(RECOVERY).map(
|
||||
(m) => `${RECOVERY.sleep_id}:${m.fieldTag}`,
|
||||
);
|
||||
|
||||
/** The subset of Prisma's StringFilter semantics the filter uses. */
|
||||
function matches(filter: Prisma.StringFilter, id: string): boolean {
|
||||
if (filter.in && !(filter.in as string[]).includes(id)) return false;
|
||||
if (filter.notIn && (filter.notIn as string[]).includes(id)) return false;
|
||||
if (filter.startsWith && !id.startsWith(filter.startsWith as string)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe("deletedResourceExternalIdFilter — one night, two resources", () => {
|
||||
it("pins the recovery field-tag list to what the mapper emits", () => {
|
||||
expect(new Set(mapRecovery(RECOVERY).map((m) => m.fieldTag))).toEqual(
|
||||
new Set(WHOOP_RECOVERY_FIELD_TAGS),
|
||||
);
|
||||
});
|
||||
|
||||
it("a recovery deletion removes the recovery and leaves the night's sleep", () => {
|
||||
const filter = deletedResourceExternalIdFilter("recovery", "sleep-uuid");
|
||||
expect(SLEEP_IDS.length).toBeGreaterThan(5);
|
||||
expect(RECOVERY_IDS.filter((id) => matches(filter, id))).toEqual(
|
||||
RECOVERY_IDS,
|
||||
);
|
||||
expect(SLEEP_IDS.filter((id) => matches(filter, id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("a sleep deletion removes the sleep and leaves its recovery", () => {
|
||||
const filter = deletedResourceExternalIdFilter("sleep", "sleep-uuid");
|
||||
expect(SLEEP_IDS.filter((id) => matches(filter, id))).toEqual(SLEEP_IDS);
|
||||
expect(RECOVERY_IDS.filter((id) => matches(filter, id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("a recovery.deleted webhook passes the exact-id match to both reads", async () => {
|
||||
updateMany.mockResolvedValue({ count: 5 });
|
||||
await processWhoopNotification({
|
||||
user_id: 42,
|
||||
id: "sleep-uuid",
|
||||
type: "recovery.deleted",
|
||||
});
|
||||
const expected = {
|
||||
in: WHOOP_RECOVERY_FIELD_TAGS.map((t) => `sleep-uuid:${t}`),
|
||||
};
|
||||
expect(findMany.mock.calls[0][0].where.externalId).toEqual(expected);
|
||||
expect(updateMany.mock.calls[0][0].where.externalId).toEqual(expected);
|
||||
});
|
||||
});
|
||||
+25
-2
@@ -180,6 +180,12 @@ export interface WhoopRecovery {
|
||||
/** Sleep activity record. */
|
||||
export interface WhoopSleep {
|
||||
id: string;
|
||||
/**
|
||||
* The cycle this sleep belongs to. A v2 recovery is read through its cycle,
|
||||
* and the recovery webhook carries the SLEEP id, so the targeted recovery
|
||||
* refresh resolves sleep -> cycle -> recovery through this field.
|
||||
*/
|
||||
cycle_id?: number;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -449,8 +455,10 @@ export function fetchCycleById(
|
||||
|
||||
/**
|
||||
* WHOOP v2 has no recovery-by-recovery-id route — a recovery is read through
|
||||
* its cycle (`GET /v2/cycle/{cycleId}/recovery`). The recovery webhook carries
|
||||
* the cycle id, so the targeted refresh resolves the recovery from there.
|
||||
* its cycle (`GET /v2/cycle/{cycleId}/recovery`). The v2 recovery webhook
|
||||
* carries the id of the associated SLEEP, not the cycle, so the targeted
|
||||
* refresh first reads the sleep for its `cycle_id` (see
|
||||
* `syncWhoopRecoveryById`).
|
||||
*/
|
||||
export function fetchRecoveryByCycleId(
|
||||
accessToken: string,
|
||||
@@ -523,6 +531,21 @@ export interface MappedMeasurement {
|
||||
reconstructed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every field-tag `mapRecovery` can emit. A recovery's rows are keyed
|
||||
* `<sleep_id>:<fieldTag>` and share the `<sleep_id>:` prefix with the rows of
|
||||
* the sleep it belongs to, so a recovery deletion must name these exact ids
|
||||
* rather than match on the prefix (which would take the night's sleep rows
|
||||
* with it). Pinned against the mapper by a test.
|
||||
*/
|
||||
export const WHOOP_RECOVERY_FIELD_TAGS = [
|
||||
"recovery",
|
||||
"hrv_rmssd",
|
||||
"rhr",
|
||||
"spo2",
|
||||
"skin_temp",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Map one WHOOP recovery record into Measurement readings. An unscored record
|
||||
* (`score === null`) yields nothing. `recovery.score.recovery_score` →
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
* sleep UUID (v2 recovery carries `sleep_id`, not a stable recovery id) so the
|
||||
* externalId is stable across re-scores.
|
||||
*/
|
||||
import { fetchRecoveries, fetchRecoveryByCycleId, mapRecovery } from "./client";
|
||||
import {
|
||||
fetchRecoveries,
|
||||
fetchRecoveryByCycleId,
|
||||
fetchSleepById,
|
||||
mapRecovery,
|
||||
} from "./client";
|
||||
import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
@@ -71,24 +76,32 @@ export async function syncUserRecovery(
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook-driven targeted refresh: resolve ONE recovery by its cycle id and
|
||||
* upsert its readings, instead of re-walking the whole collection. WHOOP v2
|
||||
* reads a recovery through its cycle (`/v2/cycle/{cycleId}/recovery`), and the
|
||||
* recovery webhook carries the cycle id. An unscored / since-deleted record
|
||||
* yields nothing. Does NOT advance the resource cursor — a single-id refresh
|
||||
* proves nothing about records between the cursor and now, so the cron/overlap
|
||||
* path stays responsible for moving the cursor.
|
||||
* Webhook-driven targeted refresh: resolve ONE recovery and upsert its
|
||||
* readings, instead of re-walking the whole collection. The v2 recovery
|
||||
* webhook carries the id of the associated SLEEP (a UUID), but WHOOP reads a
|
||||
* recovery only through its cycle (`/v2/cycle/{cycleId}/recovery`), so the
|
||||
* sleep is read first for its `cycle_id`. Passing the sleep UUID as a cycle id
|
||||
* answered 404 on every recovery webhook and booked each one as a sync
|
||||
* failure. An unscored / since-deleted record yields nothing. Does NOT advance
|
||||
* the resource cursor — a single-id refresh proves nothing about records
|
||||
* between the cursor and now, so the cron/overlap path stays responsible for
|
||||
* moving the cursor.
|
||||
*/
|
||||
export async function syncWhoopRecoveryById(
|
||||
userId: string,
|
||||
cycleId: string,
|
||||
sleepId: string,
|
||||
): Promise<number> {
|
||||
const tokenInfo = await getValidToken(userId);
|
||||
if (!tokenInfo) return 0;
|
||||
|
||||
let record: Awaited<ReturnType<typeof fetchRecoveryByCycleId>>;
|
||||
try {
|
||||
record = await fetchRecoveryByCycleId(tokenInfo.accessToken, cycleId);
|
||||
const sleep = await fetchSleepById(tokenInfo.accessToken, sleepId);
|
||||
if (sleep.cycle_id == null) return 0;
|
||||
record = await fetchRecoveryByCycleId(
|
||||
tokenInfo.accessToken,
|
||||
String(sleep.cycle_id),
|
||||
);
|
||||
} catch (err) {
|
||||
return handleCollectionFetchError("recovery", userId, err);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ import { getClientIp } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
import { WHOOP_RECOVERY_FIELD_TAGS } from "./client";
|
||||
|
||||
/**
|
||||
* Maximum age of a webhook timestamp before it is treated as a replay. Five
|
||||
@@ -119,6 +120,31 @@ export function verifyWhoopSignature(args: {
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `externalId` match for the rows a `<resource>.deleted` event removes.
|
||||
*
|
||||
* Rows are keyed `<resource-id>:<field-tag>`, but the v2 recovery webhook
|
||||
* carries the id of the associated SLEEP, and a recovery's rows are keyed off
|
||||
* that same sleep id (`<sleep_id>:hrv_rmssd`, ...). The sleep's own rows share
|
||||
* the prefix (`<sleep_id>:sleep_eff`, `<sleep_id>:seg:REM`, ...). A prefix
|
||||
* match on a recovery deletion therefore took the whole night's sleep with it,
|
||||
* and a sleep deletion took the recovery. Recovery names its exact ids; sleep
|
||||
* keeps the prefix minus those ids; every other resource keeps the prefix.
|
||||
*/
|
||||
export function deletedResourceExternalIdFilter(
|
||||
resource: string | undefined,
|
||||
resourceId: string,
|
||||
): Prisma.StringFilter {
|
||||
const recoveryIds = WHOOP_RECOVERY_FIELD_TAGS.map(
|
||||
(tag) => `${resourceId}:${tag}`,
|
||||
);
|
||||
if (resource === "recovery") return { in: recoveryIds };
|
||||
if (resource === "sleep") {
|
||||
return { startsWith: `${resourceId}:`, notIn: recoveryIds };
|
||||
}
|
||||
return { startsWith: `${resourceId}:` };
|
||||
}
|
||||
|
||||
interface WhoopWebhookBody {
|
||||
user_id?: number | string;
|
||||
id?: number | string;
|
||||
@@ -160,11 +186,11 @@ export async function processWhoopNotification(
|
||||
const queue = WHOOP_RESOURCE_QUEUE[resource ?? ""];
|
||||
|
||||
if (verb === "deleted") {
|
||||
// Soft-delete every matching row for this user + WHOOP resource id.
|
||||
// `externalId` is `<whoop-resource-uuid>:<field-tag>` so a `startsWith`
|
||||
// match catches every measurement derived from the deleted resource.
|
||||
// Soft-delete every row derived from the deleted resource for this user.
|
||||
// See `deletedResourceExternalIdFilter` for why the match is per resource.
|
||||
if (resourceId) {
|
||||
const now = new Date();
|
||||
const externalId = deletedResourceExternalIdFilter(resource, resourceId);
|
||||
// Collect the affected `(type, measuredAt)` pairs BEFORE the
|
||||
// soft-delete — the rollup tail below needs to know which
|
||||
// `(type, day)` buckets lose rows, and after the updateMany the
|
||||
@@ -173,7 +199,7 @@ export async function processWhoopNotification(
|
||||
where: {
|
||||
userId: connection.userId,
|
||||
source: "WHOOP",
|
||||
externalId: { startsWith: `${resourceId}:` },
|
||||
externalId,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { type: true, measuredAt: true },
|
||||
@@ -182,7 +208,7 @@ export async function processWhoopNotification(
|
||||
where: {
|
||||
userId: connection.userId,
|
||||
source: "WHOOP",
|
||||
externalId: { startsWith: `${resourceId}:` },
|
||||
externalId,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
|
||||
@@ -101,6 +101,7 @@ interface FakeActivity {
|
||||
steps?: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
brand?: number;
|
||||
}
|
||||
|
||||
function installFetchMock(entries: FakeActivity[]) {
|
||||
@@ -414,6 +415,28 @@ describe("syncUserActivity — field mapping + idempotency", () => {
|
||||
expect(types).not.toContain("WALKING_RUNNING_DISTANCE");
|
||||
});
|
||||
|
||||
it("skips a day Health Mate holds from another app (brand 18)", async () => {
|
||||
// An Apple Health or Google Fit day written into Health Mate comes back
|
||||
// from Getactivity as brand 18 ("external"). It reaches HealthLog from
|
||||
// its own integration; under WITHINGS it was a second copy.
|
||||
installFetchMock([
|
||||
{ date: "2026-05-12", steps: 8000, calories: 380, brand: 1 },
|
||||
{ date: "2026-05-13", steps: 9100, calories: 410, brand: 18 },
|
||||
]);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
await syncUserActivity("user-1");
|
||||
|
||||
const rows = createManyRows();
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map((r) => r.value)).toEqual(
|
||||
expect.not.arrayContaining([9100, 410]),
|
||||
);
|
||||
});
|
||||
|
||||
it("ingests a 0-step rest day rather than dropping it", async () => {
|
||||
// Withings reports 0 steps for a day a user spent in bed — that's
|
||||
// a valid datapoint, not a missing one. The mapper must not skip
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
@@ -105,18 +106,71 @@ interface FakeSegment {
|
||||
id?: number;
|
||||
}
|
||||
|
||||
function installFetchMock(segments: FakeSegment[]) {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 0,
|
||||
body: { series: segments },
|
||||
}),
|
||||
}));
|
||||
type FakeSummary = {
|
||||
id?: number;
|
||||
startdate: number;
|
||||
enddate: number;
|
||||
modified?: number;
|
||||
data?: Record<string, number | null>;
|
||||
};
|
||||
|
||||
/** The night list Withings would derive from these segments, one per session. */
|
||||
function nightsOf(segments: FakeSegment[]): FakeSummary[] {
|
||||
const bySession = new Map<string, FakeSummary>();
|
||||
for (const seg of segments) {
|
||||
const key = String(seg.id ?? "no-id");
|
||||
const night = bySession.get(key);
|
||||
bySession.set(
|
||||
key,
|
||||
night
|
||||
? {
|
||||
...night,
|
||||
startdate: Math.min(night.startdate, seg.startdate),
|
||||
enddate: Math.max(night.enddate, seg.enddate),
|
||||
}
|
||||
: { id: seg.id, startdate: seg.startdate, enddate: seg.enddate },
|
||||
);
|
||||
}
|
||||
return [...bySession.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer the two Sleep v2 actions the way Withings does: `getsummary` lists
|
||||
* the nights, `get` returns the segments inside the requested span, and no
|
||||
* more than its first 24 hours.
|
||||
*/
|
||||
function installSleepApi(segments: FakeSegment[], summaries: FakeSummary[]) {
|
||||
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
|
||||
const params = new URLSearchParams(init.body);
|
||||
if (params.get("action") === "getsummary") {
|
||||
return {
|
||||
status: 200,
|
||||
json: async () => ({ status: 0, body: { series: summaries } }),
|
||||
};
|
||||
}
|
||||
// Sleep v2 Get: "only the first 24h after startdate will be returned".
|
||||
const from = Number(params.get("startdate"));
|
||||
const to = Math.min(Number(params.get("enddate")), from + 24 * 3600);
|
||||
return {
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 0,
|
||||
body: {
|
||||
series: segments.filter(
|
||||
(seg) => seg.startdate >= from && seg.enddate <= to,
|
||||
),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function installFetchMock(segments: FakeSegment[]) {
|
||||
return installSleepApi(segments, nightsOf(segments));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
transactionMock.mockImplementation(async (run: (tx: unknown) => unknown) =>
|
||||
@@ -144,6 +198,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
|
||||
scope: "user.metrics,user.activity",
|
||||
} as never);
|
||||
// No stage row stored yet: every listed night is one to read.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -158,21 +214,9 @@ afterEach(() => {
|
||||
*/
|
||||
function installSegmentThenSummaryFetch(
|
||||
segments: FakeSegment[],
|
||||
summaries: Array<{
|
||||
id?: number;
|
||||
startdate: number;
|
||||
enddate: number;
|
||||
data?: Record<string, number | null>;
|
||||
}>,
|
||||
summaries: FakeSummary[],
|
||||
) {
|
||||
let call = 0;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
const body = call === 0 ? { series: segments } : { series: summaries };
|
||||
call++;
|
||||
return { status: 200, json: async () => ({ status: 0, body }) };
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
return installSleepApi(segments, summaries);
|
||||
}
|
||||
|
||||
describe("mapWithingsSleepSummary", () => {
|
||||
@@ -559,44 +603,50 @@ describe("syncUserSleep — segment writes + idempotency", () => {
|
||||
expect(byType.get("OXYGEN_SATURATION")?.value).toBe(95);
|
||||
});
|
||||
|
||||
it("keeps stage segments when the summary fetch fails (v1.18.10 P0)", async () => {
|
||||
let call = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
const isSummary = call > 0;
|
||||
call++;
|
||||
return {
|
||||
status: 200,
|
||||
json: async () =>
|
||||
isSummary
|
||||
? { status: 293 } // summary errors
|
||||
: {
|
||||
status: 0,
|
||||
body: {
|
||||
series: [
|
||||
{
|
||||
startdate: 1715000000,
|
||||
enddate: 1715003600,
|
||||
state: 2,
|
||||
id: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
it("reads the last 24 hours and books the leg failed when the summary fails", async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
|
||||
const params = new URLSearchParams(init.body);
|
||||
return {
|
||||
status: 200,
|
||||
json: async () =>
|
||||
params.get("action") === "getsummary"
|
||||
? { status: 293 } // summary errors
|
||||
: {
|
||||
status: 0,
|
||||
body: {
|
||||
series: [
|
||||
{
|
||||
startdate: 1715000000,
|
||||
enddate: 1715003600,
|
||||
state: 2,
|
||||
id: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const imported = await syncUserSleep("user-1");
|
||||
// The one stage segment is still written; the summary failure is swallowed.
|
||||
|
||||
// Without the night list one slice of the last 24 hours is read, so
|
||||
// last night still lands; it is not swept, since a slice can cut a night.
|
||||
expect(imported).toBe(1);
|
||||
expect(reconcileMock).toHaveBeenCalledTimes(1);
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
|
||||
leg: "sleep",
|
||||
});
|
||||
const stageCalls = fetchMock.mock.calls
|
||||
.map(([, init]) => new URLSearchParams(init.body))
|
||||
.filter((p) => p.get("action") === "get");
|
||||
expect(stageCalls).toHaveLength(1);
|
||||
expect(
|
||||
Number(stageCalls[0]!.get("enddate")) -
|
||||
Number(stageCalls[0]!.get("startdate")),
|
||||
).toBe(24 * 3600);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
// The failure is the leg's, not a warning beside a green leg.
|
||||
expect(recordSyncSuccess).not.toHaveBeenCalled();
|
||||
expect(recordSyncFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ integration: "withings", leg: "sleep" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates a ScanWatch night with no REM segment (no all-zeros row)", async () => {
|
||||
@@ -848,3 +898,144 @@ describe("syncUserSleep — Withings API plan (status 277)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncUserSleep — one stage read per night (Sleep v2 Get answers 24 h)", () => {
|
||||
const NOW = new Date("2026-10-21T09:00:00.000Z");
|
||||
const nowUnix = Math.floor(NOW.getTime() / 1000);
|
||||
const DAY = 24 * 3600;
|
||||
|
||||
/** Night `n` days back: 22:00 to 06:00, one CORE and one DEEP segment. */
|
||||
function night(n: number, id: number) {
|
||||
const end = nowUnix - 3 * 3600 - n * DAY; // 06:00 that morning
|
||||
const startdate = end - 8 * 3600;
|
||||
return {
|
||||
summary: { id, startdate, enddate: end },
|
||||
segments: [
|
||||
{ startdate, enddate: startdate + 4 * 3600, state: 1, id },
|
||||
{ startdate: startdate + 4 * 3600, enddate: end, state: 2, id },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function stageReads(fetchMock: ReturnType<typeof installSleepApi>) {
|
||||
return fetchMock.mock.calls
|
||||
.map(([, init]) => new URLSearchParams(init.body))
|
||||
.filter((p) => p.get("action") === "get")
|
||||
.map((p) => [Number(p.get("startdate")), Number(p.get("enddate"))]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reads every listed night with its own span and lands all of them", async () => {
|
||||
// Before, one read over the 30-day window answered only its first 24
|
||||
// hours: last night's stages came weeks late.
|
||||
const nights = [0, 3, 9].map((n, i) => night(n, 100 + i));
|
||||
const fetchMock = installSleepApi(
|
||||
nights.flatMap((x) => x.segments),
|
||||
nights.map((x) => x.summary),
|
||||
);
|
||||
|
||||
const imported = await syncUserSleep("user-1");
|
||||
|
||||
expect(imported).toBe(6);
|
||||
const bySpan = (a: number[], b: number[]) => a[0]! - b[0]!;
|
||||
expect([...stageReads(fetchMock)].sort(bySpan)).toEqual(
|
||||
nights.map((x) => [x.summary.startdate, x.summary.enddate]).sort(bySpan),
|
||||
);
|
||||
for (const [from, to] of stageReads(fetchMock)) {
|
||||
expect(to - from).toBeLessThanOrEqual(DAY);
|
||||
}
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
|
||||
leg: "sleep",
|
||||
});
|
||||
});
|
||||
|
||||
it("re-reads the recent nights every run and skips older nights already stored", async () => {
|
||||
const recent = night(0, 1);
|
||||
const stored = night(5, 2);
|
||||
const missing = night(6, 3);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ measuredAt: new Date(stored.summary.enddate * 1000) },
|
||||
] as never);
|
||||
// The recent night is stored too; it is read again for a re-score.
|
||||
const fetchMock = installSleepApi(
|
||||
[...recent.segments, ...stored.segments, ...missing.segments],
|
||||
[recent.summary, stored.summary, missing.summary],
|
||||
);
|
||||
|
||||
await syncUserSleep("user-1");
|
||||
|
||||
expect(stageReads(fetchMock)).toEqual([
|
||||
[recent.summary.startdate, recent.summary.enddate],
|
||||
[missing.summary.startdate, missing.summary.enddate],
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-reads an older stored night Withings changed in the last 48 hours", async () => {
|
||||
const rescored = night(6, 4);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ measuredAt: new Date(rescored.summary.enddate * 1000) },
|
||||
] as never);
|
||||
const fetchMock = installSleepApi(rescored.segments, [
|
||||
{ ...rescored.summary, modified: nowUnix - 3600 } as FakeSummary,
|
||||
]);
|
||||
|
||||
await syncUserSleep("user-1");
|
||||
|
||||
expect(stageReads(fetchMock)).toEqual([
|
||||
[rescored.summary.startdate, rescored.summary.enddate],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads at most a run's budget of missing nights, rotating which", async () => {
|
||||
const nights = Array.from({ length: 12 }, (_, i) => night(i + 3, i + 1));
|
||||
const fetchMock = installSleepApi(
|
||||
nights.flatMap((x) => x.segments),
|
||||
nights.map((x) => x.summary),
|
||||
);
|
||||
|
||||
await syncUserSleep("user-1");
|
||||
const first = stageReads(fetchMock);
|
||||
expect(first).toHaveLength(8);
|
||||
|
||||
fetchMock.mockClear();
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 3600_000));
|
||||
await syncUserSleep("user-1");
|
||||
const second = stageReads(fetchMock);
|
||||
expect(second).toHaveLength(8);
|
||||
expect(second).not.toEqual(first);
|
||||
});
|
||||
|
||||
it("reads every night of the window on a full sync", async () => {
|
||||
const nights = Array.from({ length: 12 }, (_, i) => night(i + 3, i + 1));
|
||||
const fetchMock = installSleepApi(
|
||||
nights.flatMap((x) => x.segments),
|
||||
nights.map((x) => x.summary),
|
||||
);
|
||||
|
||||
await syncUserSleep("user-1", { fullSync: true });
|
||||
|
||||
expect(stageReads(fetchMock)).toHaveLength(12);
|
||||
});
|
||||
|
||||
it("caps a night longer than a day at the 24 hours one read can return", async () => {
|
||||
const startdate = nowUnix - 30 * 3600;
|
||||
const enddate = nowUnix - 2 * 3600; // 28 hours in bed
|
||||
const fetchMock = installSleepApi(
|
||||
[{ startdate, enddate: startdate + 3600, state: 1, id: 5 }],
|
||||
[{ id: 5, startdate, enddate }],
|
||||
);
|
||||
|
||||
await syncUserSleep("user-1");
|
||||
|
||||
expect(stageReads(fetchMock)).toEqual([[startdate, startdate + DAY]]);
|
||||
// A capped read did not see the whole night, so the night is not swept.
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,9 @@ const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/v2/measure";
|
||||
*/
|
||||
const ACTIVITY_BACKFILL_DAYS = 30;
|
||||
|
||||
/** `brand` of a Getactivity day that came from outside Withings. */
|
||||
const WITHINGS_EXTERNAL_BRAND = 18;
|
||||
|
||||
/**
|
||||
* Withings `getactivity` response shape — one entry per calendar day.
|
||||
* Only the fields we consume are typed; the response carries 15+ more
|
||||
@@ -95,6 +98,13 @@ export interface WithingsActivityEntry {
|
||||
steps?: number;
|
||||
distance?: number; // metres
|
||||
calories?: number; // active kcal (NOT totalcalories)
|
||||
/**
|
||||
* Where the day came from: 1 for a Withings device or the Health Mate
|
||||
* app's own tracker, 18 for "an external way" (Getactivity: "Value is 1
|
||||
* for Withings and 18 for external"), i.e. data another app wrote into
|
||||
* Health Mate, such as an Apple Health or Google Fit day.
|
||||
*/
|
||||
brand?: number;
|
||||
// The fields below are not ingested today (see file header):
|
||||
elevation?: number;
|
||||
totalcalories?: number;
|
||||
@@ -357,8 +367,18 @@ export async function syncUserActivity(
|
||||
externalId: string;
|
||||
}
|
||||
const collected: PlannedRow[] = [];
|
||||
let external = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.date) continue;
|
||||
// A day Health Mate holds from another app is that app's data, which
|
||||
// reaches HealthLog from its own integration (Apple Health, Health
|
||||
// Connect, Google Health). Imported under WITHINGS it was a second copy
|
||||
// under the wrong name, and on a day that copy was the only Withings row
|
||||
// it could outrank a native source further down the ladder.
|
||||
if (entry.brand === WITHINGS_EXTERNAL_BRAND) {
|
||||
external++;
|
||||
continue;
|
||||
}
|
||||
// Local noon of the date in the user's zone: the instant reads the same
|
||||
// calendar day there. Noon UTC read as the next day from UTC+13 on
|
||||
// (Auckland in summer, Tonga, Samoa), so every daily total landed a day
|
||||
@@ -380,6 +400,12 @@ export async function syncUserActivity(
|
||||
}
|
||||
}
|
||||
|
||||
if (external > 0) {
|
||||
getEvent()?.addWarning(
|
||||
`withings activity: skipped ${external} day(s) Health Mate holds from another app`,
|
||||
);
|
||||
}
|
||||
|
||||
// A daily-activity total the app's own band calls impossible (a step count
|
||||
// past 200 000, a negative distance) is a provider artefact, not a day. Drop
|
||||
// it before the plan is read against the table, so it can neither insert a
|
||||
|
||||
+236
-92
@@ -122,6 +122,8 @@ export interface WithingsSleepSummary {
|
||||
id?: number;
|
||||
startdate: number;
|
||||
enddate: number;
|
||||
/** When Withings last changed the night (unix seconds), e.g. a re-score. */
|
||||
modified?: number;
|
||||
data?: {
|
||||
hr_average?: number | null;
|
||||
rr_average?: number | null;
|
||||
@@ -385,56 +387,145 @@ export async function syncUserSleep(
|
||||
return 0;
|
||||
}
|
||||
|
||||
void opts.fullSync;
|
||||
const now = new Date();
|
||||
const start = new Date(
|
||||
now.getTime() - SLEEP_BACKFILL_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const startUnix = Math.floor(start.getTime() / 1000);
|
||||
const endUnix = Math.floor(now.getTime() / 1000);
|
||||
|
||||
let segments: WithingsSleepSegment[];
|
||||
// The nights come first: `action=getsummary` lists every night of the
|
||||
// window (and carries its vitals), and the stage timeline is then read per
|
||||
// night. `action=get` answers only the first 24 hours after its
|
||||
// `startdate` ("If your input startdate and enddate are separated by more
|
||||
// than 24h, only the first 24h after startdate will be returned", Sleep v2
|
||||
// Get); asked for the whole window it returned the night a month back and
|
||||
// nothing since, so every night's stages arrived when the sliding window
|
||||
// reached it, weeks late.
|
||||
//
|
||||
// Withings API plans (2026-10-12): the sleep score, the overnight HRV and
|
||||
// the respiration rate are Advanced Biomarkers, and one of them in
|
||||
// `data_fields` has the whole summary refused with 277. Ask only for what
|
||||
// the plan allows, remember what it refused, and re-probe weekly.
|
||||
let summaries: WithingsSleepSummary[] = [];
|
||||
let summaryFailure: unknown = null;
|
||||
let nightsKnown = false;
|
||||
try {
|
||||
segments = await fetchWithingsSleep(
|
||||
tokenInfo.accessToken,
|
||||
startUnix,
|
||||
endUnix,
|
||||
);
|
||||
} catch (err) {
|
||||
// The stage timeline is a free-plan biomarker, so a plan refusal here is
|
||||
// not expected. If Withings ever moves it, the leg reads nothing rather
|
||||
// than failing: a 277 must not climb the strike ladder and park the
|
||||
// measures, activity and ECG legs along with it.
|
||||
if (isPlanGatedError(err)) {
|
||||
annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } });
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP });
|
||||
return 0;
|
||||
}
|
||||
// v1.4.43 — typed-classification path (see sync-activity.ts
|
||||
// for the full rationale). The thrown `WithingsApiError` carries
|
||||
// the classification verdict; `recordWithingsSyncFailure` routes
|
||||
// it through `classifyError`, which falls back to the legacy regex
|
||||
// for un-prototyped errors (pg-boss JSON round-trip).
|
||||
//
|
||||
// BL-P3-2 defence-in-depth — symmetric to sync-activity. A 403 on
|
||||
// this scope-gated endpoint always means scope-missing or
|
||||
// token-revoked; force `reauth_required` so the 3-strike alert
|
||||
// still pages on the unexpected case.
|
||||
const withingsStatus =
|
||||
err instanceof WithingsApiError ? err.withingsStatus : undefined;
|
||||
if (withingsStatus === 403) {
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "withings",
|
||||
kind: "reauth_required",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
errorCode: "403",
|
||||
leg: WITHINGS_LEG_SLEEP,
|
||||
const planRow = await prisma.withingsConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { apiPlanState: true },
|
||||
});
|
||||
const sleepPlan = parseWithingsApiPlanState(
|
||||
planRow?.apiPlanState,
|
||||
).sleepSummary;
|
||||
const reprobed = isPlanReprobeDue(sleepPlan, now);
|
||||
const skip = planSkipFor(sleepPlan, now);
|
||||
const plan = await requestWithinPlan({
|
||||
keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)),
|
||||
knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS,
|
||||
fetch: (fields) =>
|
||||
fetchWithingsSleepSummary(
|
||||
tokenInfo.accessToken,
|
||||
ymdUtc(start),
|
||||
ymdUtc(now),
|
||||
fields,
|
||||
),
|
||||
});
|
||||
summaries = plan.results;
|
||||
nightsKnown = plan.acceptedAny;
|
||||
if (
|
||||
shouldPersistPlanLeg({
|
||||
gated: plan.gated,
|
||||
reprobed,
|
||||
acceptedAny: plan.acceptedAny,
|
||||
})
|
||||
) {
|
||||
await persistWithingsPlanLeg(userId, "sleepSummary", {
|
||||
unavailable: [...skip, ...plan.unavailable],
|
||||
checkedAt: planCheckedAtFor(sleepPlan, reprobed, now),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Booked as this leg's failure once the stages are written: the summary
|
||||
// carries the vitals and names the nights, and a run without it is not
|
||||
// a healthy one. It used to be a warning, and the leg read green.
|
||||
summaryFailure = err;
|
||||
getEvent()?.addWarning(
|
||||
`withings sleep: summary fetch failed for ${userId}: ${err}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Without the night list (the summary failed, or the plan refused every
|
||||
// field it was asked for) the last 24 hours are read as one slice, so last
|
||||
// night still lands. Such a slice can cut a night, so it is never swept.
|
||||
const windows: StageWindow[] = nightsKnown
|
||||
? await stageWindowsToFetch(userId, summaries, now, {
|
||||
all: opts.fullSync === true,
|
||||
})
|
||||
: [
|
||||
{
|
||||
startdate: endUnix - DAY_SECONDS,
|
||||
enddate: endUnix,
|
||||
wholeNight: false,
|
||||
},
|
||||
];
|
||||
|
||||
const segments: WithingsSleepSegment[] = [];
|
||||
const wholeNightSessions = new Set<number>();
|
||||
const seenSegments = new Set<string>();
|
||||
for (const window of windows) {
|
||||
let series: WithingsSleepSegment[];
|
||||
try {
|
||||
series = await fetchWithingsSleep(
|
||||
tokenInfo.accessToken,
|
||||
window.startdate,
|
||||
window.enddate,
|
||||
);
|
||||
} catch (err) {
|
||||
// The stage timeline is a free-plan biomarker, so a plan refusal here
|
||||
// is not expected. If Withings ever moves it, the leg reads no stages
|
||||
// rather than failing: a 277 must not climb the strike ladder and park
|
||||
// the measures, activity and ECG legs along with it.
|
||||
if (isPlanGatedError(err)) {
|
||||
annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } });
|
||||
break;
|
||||
}
|
||||
// v1.4.43 — typed-classification path (see sync-activity.ts
|
||||
// for the full rationale). The thrown `WithingsApiError` carries
|
||||
// the classification verdict; `recordWithingsSyncFailure` routes
|
||||
// it through `classifyError`, which falls back to the legacy regex
|
||||
// for un-prototyped errors (pg-boss JSON round-trip).
|
||||
//
|
||||
// BL-P3-2 defence-in-depth — symmetric to sync-activity. A 403 on
|
||||
// this scope-gated endpoint always means scope-missing or
|
||||
// token-revoked; force `reauth_required` so the 3-strike alert
|
||||
// still pages on the unexpected case.
|
||||
const withingsStatus =
|
||||
err instanceof WithingsApiError ? err.withingsStatus : undefined;
|
||||
if (withingsStatus === 403) {
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "withings",
|
||||
kind: "reauth_required",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
errorCode: "403",
|
||||
leg: WITHINGS_LEG_SLEEP,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_SLEEP);
|
||||
throw err;
|
||||
}
|
||||
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_SLEEP);
|
||||
throw err;
|
||||
for (const segment of series) {
|
||||
// Two windows can return the same segment (a night longer than its
|
||||
// 24-hour slice, a nap inside a night); it is written once.
|
||||
const key = `${segment.id ?? "no-id"}:${segment.startdate}`;
|
||||
if (seenSegments.has(key)) continue;
|
||||
seenSegments.add(key);
|
||||
segments.push(segment);
|
||||
if (window.wholeNight && typeof segment.id === "number") {
|
||||
wholeNightSessions.add(segment.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
@@ -528,63 +619,19 @@ export async function syncUserSleep(
|
||||
// running-index row for re-fetched sessions. Sessions absent from this fetch
|
||||
// are never touched; a sweep failure never fails the sync (the session stays
|
||||
// inside the rolling 30-day window, so the hourly cron retries it).
|
||||
const sweeps: SleepSegmentSweep[] = [...freshBySession].map(
|
||||
([sessionId, keepIds]) => ({
|
||||
const sweeps: SleepSegmentSweep[] = [...freshBySession]
|
||||
// Only a session read across its whole night is swept: a 24-hour slice
|
||||
// can cut one, and sweeping it would retire the part outside the slice.
|
||||
.filter(([sessionId]) => wholeNightSessions.has(sessionId))
|
||||
.map(([sessionId, keepIds]) => ({
|
||||
prefix: `withings:sleep:${userId}:${sessionId}:`,
|
||||
keepIds,
|
||||
}),
|
||||
);
|
||||
}));
|
||||
await sweepStaleSleepSegments(userId, "WITHINGS", sweeps);
|
||||
|
||||
// v1.18.10 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV
|
||||
// / avg SpO2 / sleep score) from the per-night summary. Fetch failure stays
|
||||
// isolated from the stage writes; database failures are hard sync failures.
|
||||
//
|
||||
// Withings API plans (2026-10-12): the sleep score, the overnight HRV and the
|
||||
// respiration rate are Advanced Biomarkers, and one of them in `data_fields`
|
||||
// has the whole summary refused with 277. Ask only for what the plan allows,
|
||||
// remember what it refused, and re-probe weekly.
|
||||
let summaries: WithingsSleepSummary[] = [];
|
||||
try {
|
||||
const planRow = await prisma.withingsConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { apiPlanState: true },
|
||||
});
|
||||
const sleepPlan = parseWithingsApiPlanState(
|
||||
planRow?.apiPlanState,
|
||||
).sleepSummary;
|
||||
const reprobed = isPlanReprobeDue(sleepPlan, now);
|
||||
const skip = planSkipFor(sleepPlan, now);
|
||||
const plan = await requestWithinPlan({
|
||||
keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)),
|
||||
knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS,
|
||||
fetch: (fields) =>
|
||||
fetchWithingsSleepSummary(
|
||||
tokenInfo.accessToken,
|
||||
ymdUtc(start),
|
||||
ymdUtc(now),
|
||||
fields,
|
||||
),
|
||||
});
|
||||
summaries = plan.results;
|
||||
if (
|
||||
shouldPersistPlanLeg({
|
||||
gated: plan.gated,
|
||||
reprobed,
|
||||
acceptedAny: plan.acceptedAny,
|
||||
})
|
||||
) {
|
||||
await persistWithingsPlanLeg(userId, "sleepSummary", {
|
||||
unavailable: [...skip, ...plan.unavailable],
|
||||
checkedAt: planCheckedAtFor(sleepPlan, reprobed, now),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`withings sleep: summary fetch failed for ${userId}: ${err}`,
|
||||
);
|
||||
}
|
||||
|
||||
// / avg SpO2 / sleep score) from the per-night summary read above. Database
|
||||
// failures are hard sync failures.
|
||||
await prisma
|
||||
.$transaction(
|
||||
async (tx) => {
|
||||
@@ -656,6 +703,103 @@ export async function syncUserSleep(
|
||||
() => {},
|
||||
);
|
||||
|
||||
if (summaryFailure !== null) {
|
||||
await recordWithingsSyncFailure(userId, summaryFailure, WITHINGS_LEG_SLEEP);
|
||||
return imported;
|
||||
}
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP });
|
||||
return imported;
|
||||
}
|
||||
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
/** Nights that ended this recently are read again on every run (re-scores). */
|
||||
const RECENT_NIGHT_SECONDS = 2 * DAY_SECONDS;
|
||||
|
||||
/**
|
||||
* Stage reads per run beyond the recent nights. A new connection, or a
|
||||
* window the sleep-timeline backfill just cleared, fills in over a few runs
|
||||
* instead of firing thirty requests at once; a full sync reads every night.
|
||||
*/
|
||||
export const SLEEP_STAGE_NIGHTS_PER_RUN = 8;
|
||||
|
||||
/** One `action=get` read: a night's own span, at most 24 hours. */
|
||||
interface StageWindow {
|
||||
startdate: number;
|
||||
enddate: number;
|
||||
/** The read covers the whole night, so its session may be swept. */
|
||||
wholeNight: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nights whose stage timeline this run reads, from the summary's night
|
||||
* list: every night that ended, or that Withings changed (`modified`), in the
|
||||
* last 48 hours, since Withings re-scores a night after the fact; then nights
|
||||
* that hold no Withings stage row yet,
|
||||
* up to `SLEEP_STAGE_NIGHTS_PER_RUN`. The missing nights rotate by the hour,
|
||||
* so a night that never carries stages (a tracker without them) cannot keep
|
||||
* the ones behind it waiting.
|
||||
*/
|
||||
async function stageWindowsToFetch(
|
||||
userId: string,
|
||||
summaries: readonly WithingsSleepSummary[],
|
||||
now: Date,
|
||||
opts: { all: boolean },
|
||||
): Promise<StageWindow[]> {
|
||||
const nights = new Map<
|
||||
string,
|
||||
{ startdate: number; enddate: number; changed: number }
|
||||
>();
|
||||
for (const s of summaries) {
|
||||
if (!Number.isFinite(s.startdate) || !Number.isFinite(s.enddate)) continue;
|
||||
if (s.enddate <= s.startdate) continue;
|
||||
const modified = Number.isFinite(s.modified) ? s.modified! : 0;
|
||||
const key = `${s.startdate}:${s.enddate}`;
|
||||
nights.set(key, {
|
||||
startdate: s.startdate,
|
||||
enddate: s.enddate,
|
||||
changed: Math.max(s.enddate, modified, nights.get(key)?.changed ?? 0),
|
||||
});
|
||||
}
|
||||
const all = [...nights.values()].sort((x, y) => y.enddate - x.enddate);
|
||||
const toWindow = (n: {
|
||||
startdate: number;
|
||||
enddate: number;
|
||||
}): StageWindow => ({
|
||||
startdate: n.startdate,
|
||||
enddate: Math.min(n.enddate, n.startdate + DAY_SECONDS),
|
||||
wholeNight: n.enddate - n.startdate <= DAY_SECONDS,
|
||||
});
|
||||
if (opts.all || all.length === 0) return all.map(toWindow);
|
||||
|
||||
const nowUnix = Math.floor(now.getTime() / 1000);
|
||||
// Recent: ended, or was changed by Withings, within the last 48 hours.
|
||||
const recent = all.filter((n) => n.changed >= nowUnix - RECENT_NIGHT_SECONDS);
|
||||
const older = all.filter((n) => n.changed < nowUnix - RECENT_NIGHT_SECONDS);
|
||||
if (older.length === 0) return recent.map(toWindow);
|
||||
|
||||
// Segments are stamped at their END, so a night holds a stage row when one
|
||||
// ends inside (startdate, enddate]. One read over the span of the older
|
||||
// nights, on the `(user_id, type, measured_at)` index.
|
||||
const first = Math.min(...older.map((n) => n.startdate));
|
||||
const last = Math.max(...older.map((n) => n.enddate));
|
||||
const stored = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "WITHINGS",
|
||||
type: SLEEP_TYPE,
|
||||
deletedAt: null,
|
||||
measuredAt: { gt: new Date(first * 1000), lte: new Date(last * 1000) },
|
||||
},
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
const endsAt = stored.map((row) => row.measuredAt.getTime() / 1000);
|
||||
const missing = older
|
||||
.filter((n) => !endsAt.some((t) => t > n.startdate && t <= n.enddate))
|
||||
.sort((x, y) => x.enddate - y.enddate);
|
||||
const budget = Math.max(0, SLEEP_STAGE_NIGHTS_PER_RUN - recent.length);
|
||||
if (missing.length <= budget) return [...recent, ...missing].map(toWindow);
|
||||
const offset = Math.floor(nowUnix / 3600) % missing.length;
|
||||
const rotated = [...missing.slice(offset), ...missing.slice(0, offset)];
|
||||
return [...recent, ...rotated.slice(0, budget)].map(toWindow);
|
||||
}
|
||||
Reference in new issue
Block a user